# Binance Live Auto Buyer Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a bounded Binance Spot live auto-buyer that scans `BTCUSDT`, `ETHUSDT`, `BNBUSDT`, and `SOLUSDT`, buys `5 USDT` at most five times per day, places a fixed 2% `STOP_LOSS_LIMIT`, and immediately sells back if protection fails.

**Architecture:** Keep the current CLI package and add focused modules for auto-buy data models, deterministic scanning, live risk checks, and two-phase execution. Extend `exchange`, `storage`, and `cli` through narrow methods so existing SMA trade-loop behavior remains unchanged. The Codex automation should only run the new CLI command; all trading logic stays in tested Python code.

**Tech Stack:** Python 3.11+, Typer, Pydantic Settings, httpx, SQLite, pytest, Binance Spot REST.

---

## File Structure

- Prerequisite: ensure the feature branch includes committed Binance Spot production `live` mode support before auto-buyer work continues.
- Create `src/binance_quant/auto_buy.py`: auto-buyer models, scanner, risk decision, execution workflow, and single-run orchestration.
- Modify `src/binance_quant/config.py`: add live auto-buyer settings with safe disabled defaults.
- Modify `src/binance_quant/models.py`: add tick-size metadata and order type data used by protective stop orders.
- Modify `src/binance_quant/exchange.py`: add ticker/order-book/klines/open-orders/order-test/quantity market sell/protective stop methods.
- Modify `src/binance_quant/storage.py`: add auto-buyer audit tables and query helpers.
- Modify `src/binance_quant/cli.py`: add `live-auto-buy-once` command.
- Modify `scripts/run.ps1`: add a wrapper action for the new single-run command.
- Modify `README.md` and `.env.example`: document live auto-buyer settings, disabled default, and key rotation warning.
- Create tests:
  - `tests/test_auto_buy_strategy.py`
  - `tests/test_auto_buy_risk.py`
  - `tests/test_auto_buy_execution.py`
  - `tests/test_auto_buy_runner.py`
- Modify existing tests:
  - `tests/test_config.py`
  - `tests/test_exchange.py`
  - `tests/test_storage.py`
  - `tests/test_cli.py`
  - `tests/test_run_script.py`

---

### Task 0: Add Production Live Mode Prerequisite

**Files:**
- Modify: `src/binance_quant/models.py`
- Modify: `src/binance_quant/config.py`
- Modify: `src/binance_quant/execution.py`
- Modify: `src/binance_quant/runner.py`
- Modify: `scripts/run.ps1`
- Modify: `.env.example`
- Modify: `README.md`
- Test: `tests/test_config.py`
- Test: `tests/test_execution.py`
- Test: `tests/test_runner.py`
- Test: `tests/test_risk.py`
- Test: `tests/test_run_script.py`

- [ ] **Step 1: Write failing live-mode tests**

Add tests equivalent to the already-approved live-mode design:

```python
def test_live_mode_requires_credentials():
    with pytest.raises(ValueError, match="live mode requires"):
        Settings(_env_file=None, mode=TradingMode.LIVE, api_key="", api_secret="")


def test_live_mode_accepts_production_urls():
    settings = Settings(
        _env_file=None,
        mode=TradingMode.LIVE,
        api_key="key",
        api_secret="secret",
        rest_base_url="https://api.binance.com/api",
        stream_base_url="wss://stream.binance.com:9443/ws",
    )

    assert settings.rest_base_url == "https://api.binance.com/api"
    assert settings.stream_base_url == "wss://stream.binance.com:9443/ws"


def test_live_execution_calls_exchange():
    exchange = FakeExchange()
    engine = ExecutionEngine(mode=TradingMode.LIVE, exchange_client=exchange)

    result = engine.execute(OrderIntent("BTCUSDT", OrderSide.BUY, Decimal("25"), "abc"))

    assert result.status == "FILLED"
    assert not result.dry_run
    assert exchange.orders == [("BTCUSDT", OrderSide.BUY, Decimal("25"), "abc")]
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_config.py tests/test_execution.py tests/test_runner.py tests/test_risk.py tests/test_run_script.py -q
```

Expected: FAIL because `TradingMode.LIVE` does not exist on this branch.

- [ ] **Step 3: Implement production live mode**

Implement the same committed behavior from the local live-mode work:

```python
class TradingMode(str, Enum):
    DRY_RUN = "dry_run"
    TESTNET_LIVE = "testnet_live"
    LIVE = "live"
```

Use production defaults when mode is `live`:

```python
LIVE_REST_BASE_URL = "https://api.binance.com/api"
LIVE_STREAM_BASE_URL = "wss://stream.binance.com:9443/ws"
```

Allow live REST hosts `api.binance.com`, `api1.binance.com`, `api2.binance.com`, `api3.binance.com`, and `api4.binance.com`. Allow live stream hosts `stream.binance.com` and `data-stream.binance.vision`. Treat `TradingMode.LIVE` like `TESTNET_LIVE` in `ExecutionEngine`, `_account_for_mode`, and `RiskEngine`.

- [ ] **Step 4: Update script and docs**

Document `BINANCE_MODE=live`, production endpoints, and the existing confirmed live sell/preflight script actions. Keep script actions guarded by explicit `-ConfirmLiveOrder` where they submit real orders.

- [ ] **Step 5: Run tests to verify pass**

Run:

```powershell
pytest tests/test_config.py tests/test_execution.py tests/test_runner.py tests/test_risk.py tests/test_run_script.py -q
```

Expected: PASS.

- [ ] **Step 6: Commit**

```powershell
git add src tests scripts README.md .env.example
git commit -m "feat: add production live mode"
```

---

### Task 1: Add Configuration And Metadata Shape

**Files:**
- Modify: `src/binance_quant/config.py`
- Modify: `src/binance_quant/models.py`
- Test: `tests/test_config.py`
- Test: `tests/test_exchange.py`

- [ ] **Step 1: Write failing settings tests**

Append these tests to `tests/test_config.py`:

```python
def test_auto_buyer_defaults_are_disabled_and_conservative():
    settings = Settings(_env_file=None)

    assert settings.live_auto_buyer_enabled is False
    assert settings.auto_buy_symbols == ("BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT")
    assert settings.auto_buy_quote_amount == Decimal("5")
    assert settings.auto_buy_daily_limit == 5
    assert settings.auto_buy_stop_loss_percent == Decimal("2")
    assert settings.auto_buy_stop_limit_buffer_percent == Decimal("0.2")
    assert settings.auto_buy_max_spread_bps == Decimal("10")
    assert settings.auto_buy_cooldown_seconds == 3600
    assert settings.auto_buy_min_score == Decimal("4")


def test_auto_buyer_symbol_list_parses_environment(monkeypatch):
    monkeypatch.setenv("BINANCE_AUTO_BUY_SYMBOLS", " ethusdt , btcusdt ")

    settings = Settings(_env_file=None)

    assert settings.auto_buy_symbols == ("ETHUSDT", "BTCUSDT")


def test_auto_buyer_rejects_empty_symbol_list():
    with pytest.raises(ValueError, match="auto_buy_symbols must contain at least one symbol"):
        Settings(_env_file=None, auto_buy_symbols="")


def test_auto_buyer_rejects_non_usdt_symbols():
    with pytest.raises(ValueError, match="auto_buy_symbols must be USDT spot symbols"):
        Settings(_env_file=None, auto_buy_symbols="BTCUSDT,ETHBTC")


def test_auto_buyer_rejects_non_positive_limits():
    with pytest.raises(ValueError, match="auto_buy_quote_amount must be positive"):
        Settings(_env_file=None, auto_buy_quote_amount=Decimal("0"))
    with pytest.raises(ValueError, match="auto_buy_daily_limit must be positive"):
        Settings(_env_file=None, auto_buy_daily_limit=0)
    with pytest.raises(ValueError, match="auto_buy_stop_loss_percent must be positive"):
        Settings(_env_file=None, auto_buy_stop_loss_percent=Decimal("0"))
```

- [ ] **Step 2: Write failing metadata parsing test**

Append this test to `tests/test_exchange.py`:

```python
def test_exchange_info_parses_price_filter_metadata():
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            json={
                "symbols": [
                    {
                        "symbol": "SOLUSDT",
                        "baseAsset": "SOL",
                        "quoteAsset": "USDT",
                        "filters": [
                            {"filterType": "LOT_SIZE", "minQty": "0.01000000", "stepSize": "0.01000000"},
                            {"filterType": "PRICE_FILTER", "minPrice": "0.01000000", "tickSize": "0.01000000"},
                            {"filterType": "MIN_NOTIONAL", "minNotional": "5.00000000"},
                        ],
                    }
                ]
            },
        )

    transport = httpx.MockTransport(handler)
    client = BinanceSpotClient(api_key="key", api_secret="secret", http_client=httpx.Client(transport=transport))

    metadata = client.exchange_info("solusdt")

    assert metadata.tick_size == Decimal("0.01000000")
    assert metadata.min_price == Decimal("0.01000000")
```

- [ ] **Step 3: Run tests to verify failure**

Run:

```powershell
pytest tests/test_config.py tests/test_exchange.py -q
```

Expected: FAIL because `Settings` lacks auto-buyer fields and `SymbolMetadata` lacks `tick_size` and `min_price`.

- [ ] **Step 4: Implement settings and metadata fields**

In `src/binance_quant/models.py`, update `SymbolMetadata`:

```python
@dataclass(frozen=True)
class SymbolMetadata:
    symbol: str
    base_asset: str
    quote_asset: str
    min_notional: Decimal
    min_qty: Decimal
    step_size: Decimal
    tick_size: Decimal = Decimal("0")
    min_price: Decimal = Decimal("0")
```

In `src/binance_quant/config.py`, add imports and fields:

```python
from typing import Any
```

Add to `Settings`:

```python
    live_auto_buyer_enabled: bool = False
    auto_buy_symbols: tuple[str, ...] | str = ("BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT")
    auto_buy_quote_amount: Decimal = Decimal("5")
    auto_buy_daily_limit: int = Field(default=5, gt=0)
    auto_buy_stop_loss_percent: Decimal = Decimal("2")
    auto_buy_stop_limit_buffer_percent: Decimal = Decimal("0.2")
    auto_buy_max_spread_bps: Decimal = Decimal("10")
    auto_buy_cooldown_seconds: int = Field(default=3600, ge=0)
    auto_buy_min_score: Decimal = Decimal("4")
```

Add validators inside `Settings`:

```python
    @field_validator("auto_buy_symbols", mode="before")
    @classmethod
    def parse_auto_buy_symbols(cls, value: Any) -> tuple[str, ...]:
        if isinstance(value, str):
            symbols = tuple(item.strip().upper() for item in value.split(",") if item.strip())
        else:
            symbols = tuple(str(item).strip().upper() for item in value if str(item).strip())
        if not symbols:
            raise ValueError("auto_buy_symbols must contain at least one symbol")
        if any(not symbol.endswith("USDT") for symbol in symbols):
            raise ValueError("auto_buy_symbols must be USDT spot symbols")
        return symbols

    @field_validator(
        "auto_buy_quote_amount",
        "auto_buy_stop_loss_percent",
        "auto_buy_stop_limit_buffer_percent",
        "auto_buy_max_spread_bps",
        "auto_buy_min_score",
    )
    @classmethod
    def positive_auto_buy_decimal(cls, value: Decimal, info) -> Decimal:
        if value <= 0:
            raise ValueError(f"{info.field_name} must be positive")
        return value

    @field_validator("auto_buy_daily_limit")
    @classmethod
    def positive_auto_buy_daily_limit(cls, value: int) -> int:
        if value <= 0:
            raise ValueError("auto_buy_daily_limit must be positive")
        return value
```

In `src/binance_quant/exchange.py`, update `exchange_info`:

```python
        price_filter = filters.get("PRICE_FILTER", {})
        return SymbolMetadata(
            symbol=symbol_info["symbol"],
            base_asset=symbol_info["baseAsset"],
            quote_asset=symbol_info["quoteAsset"],
            min_notional=min_notional,
            min_qty=Decimal(lot_size.get("minQty", "0")),
            step_size=Decimal(lot_size.get("stepSize", "0")),
            tick_size=Decimal(price_filter.get("tickSize", "0")),
            min_price=Decimal(price_filter.get("minPrice", "0")),
        )
```

- [ ] **Step 5: Run tests to verify pass**

Run:

```powershell
pytest tests/test_config.py tests/test_exchange.py -q
```

Expected: PASS.

- [ ] **Step 6: Commit**

```powershell
git add src/binance_quant/config.py src/binance_quant/models.py src/binance_quant/exchange.py tests/test_config.py tests/test_exchange.py
git commit -m "feat: add live auto buyer settings"
```

---

### Task 2: Add Deterministic Multi-Symbol Scanner

**Files:**
- Create: `src/binance_quant/auto_buy.py`
- Test: `tests/test_auto_buy_strategy.py`

- [ ] **Step 1: Write failing scanner tests**

Create `tests/test_auto_buy_strategy.py`:

```python
from decimal import Decimal

from binance_quant.auto_buy import AutoBuyMarketSnapshot, AutoBuyScanner
from binance_quant.models import Candle


def candle(symbol: str, close: str, volume: str = "10", open_time: int = 1) -> Candle:
    value = Decimal(close)
    return Candle(
        symbol=symbol,
        open_time=open_time,
        close_time=open_time + 60_000,
        open=value,
        high=value,
        low=value,
        close=value,
        volume=Decimal(volume),
    )


def snapshot(symbol: str, closes: list[str], *, bid: str = "109.9", ask: str = "110", volume: str = "12") -> AutoBuyMarketSnapshot:
    candles = [candle(symbol, close, volume=volume, open_time=index) for index, close in enumerate(closes, start=1)]
    return AutoBuyMarketSnapshot(symbol=symbol, bid=Decimal(bid), ask=Decimal(ask), candles=candles)


def test_scanner_selects_highest_scoring_eligible_symbol():
    scanner = AutoBuyScanner(symbols=("BTCUSDT", "ETHUSDT"), min_score=Decimal("4"), max_spread_bps=Decimal("20"))

    candidate = scanner.select(
        [
            snapshot("BTCUSDT", ["100", "101", "102", "103", "104", "105"], bid="104.9", ask="105"),
            snapshot("ETHUSDT", ["100", "99", "100", "101", "103", "110"], bid="109.9", ask="110"),
        ]
    )

    assert candidate is not None
    assert candidate.symbol == "ETHUSDT"
    assert candidate.score > Decimal("4")
    assert "positive 1h momentum" in candidate.reasons
    assert "short SMA above long SMA" in candidate.reasons


def test_scanner_returns_none_when_score_is_below_threshold():
    scanner = AutoBuyScanner(symbols=("BTCUSDT",), min_score=Decimal("4"), max_spread_bps=Decimal("20"))

    candidate = scanner.select([snapshot("BTCUSDT", ["100", "100", "100", "100", "100", "100"])])

    assert candidate is None


def test_scanner_rejects_wide_spread():
    scanner = AutoBuyScanner(symbols=("BTCUSDT",), min_score=Decimal("1"), max_spread_bps=Decimal("5"))

    candidate = scanner.select([snapshot("BTCUSDT", ["100", "101", "102", "103", "104", "105"], bid="100", ask="101")])

    assert candidate is None


def test_scanner_tie_breaks_by_configured_symbol_order():
    scanner = AutoBuyScanner(symbols=("ETHUSDT", "BTCUSDT"), min_score=Decimal("4"), max_spread_bps=Decimal("20"))

    candidate = scanner.select(
        [
            snapshot("BTCUSDT", ["100", "101", "102", "103", "104", "105"], bid="104.9", ask="105"),
            snapshot("ETHUSDT", ["100", "101", "102", "103", "104", "105"], bid="104.9", ask="105"),
        ]
    )

    assert candidate is not None
    assert candidate.symbol == "ETHUSDT"
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_auto_buy_strategy.py -q
```

Expected: FAIL because `binance_quant.auto_buy` does not exist.

- [ ] **Step 3: Implement scanner models and scoring**

Create `src/binance_quant/auto_buy.py`:

```python
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal, ROUND_DOWN
from typing import Iterable

from .models import AccountSnapshot, Candle, SymbolMetadata, TradingMode


BPS = Decimal("10000")


@dataclass(frozen=True)
class AutoBuyMarketSnapshot:
    symbol: str
    bid: Decimal
    ask: Decimal
    candles: list[Candle]

    def __post_init__(self) -> None:
        object.__setattr__(self, "symbol", self.symbol.upper())


@dataclass(frozen=True)
class AutoBuyCandidate:
    symbol: str
    score: Decimal
    reference_price: Decimal
    spread_bps: Decimal
    reasons: tuple[str, ...]

    def __post_init__(self) -> None:
        object.__setattr__(self, "symbol", self.symbol.upper())


class AutoBuyScanner:
    def __init__(self, symbols: tuple[str, ...], min_score: Decimal, max_spread_bps: Decimal) -> None:
        self.symbols = tuple(symbol.upper() for symbol in symbols)
        self.min_score = min_score
        self.max_spread_bps = max_spread_bps

    def select(self, snapshots: Iterable[AutoBuyMarketSnapshot]) -> AutoBuyCandidate | None:
        by_symbol = {snapshot.symbol: snapshot for snapshot in snapshots}
        candidates = [self._score(by_symbol[symbol]) for symbol in self.symbols if symbol in by_symbol]
        eligible = [candidate for candidate in candidates if candidate is not None and candidate.score >= self.min_score]
        if not eligible:
            return None
        return max(eligible, key=lambda candidate: (candidate.score, -self.symbols.index(candidate.symbol)))

    def _score(self, snapshot: AutoBuyMarketSnapshot) -> AutoBuyCandidate | None:
        if snapshot.bid <= 0 or snapshot.ask <= 0 or snapshot.ask < snapshot.bid:
            return None
        candles = snapshot.candles
        if len(candles) < 6:
            return None
        reference_price = snapshot.ask
        midpoint = (snapshot.bid + snapshot.ask) / Decimal("2")
        spread_bps = ((snapshot.ask - snapshot.bid) / midpoint) * BPS
        if spread_bps > self.max_spread_bps:
            return None

        closes = [candle.close for candle in candles]
        volumes = [candle.volume for candle in candles]
        score = Decimal("0")
        reasons: list[str] = []

        if closes[-1] > closes[-2]:
            score += Decimal("1")
            reasons.append("positive 1h momentum")
        if closes[-1] > closes[-5]:
            score += Decimal("1")
            reasons.append("positive 4h momentum")
        short_sma = sum(closes[-3:]) / Decimal("3")
        long_sma = sum(closes[-6:]) / Decimal("6")
        if short_sma > long_sma:
            score += Decimal("1")
            reasons.append("short SMA above long SMA")
        baseline_volume = sum(volumes[-6:-1]) / Decimal("5")
        if volumes[-1] >= baseline_volume:
            score += Decimal("1")
            reasons.append("volume above baseline")
        momentum = ((closes[-1] - closes[-5]) / closes[-5]) * Decimal("100")
        if momentum > 0:
            score += momentum.quantize(Decimal("0.0001"))

        return AutoBuyCandidate(snapshot.symbol, score, reference_price, spread_bps, tuple(reasons))
```

- [ ] **Step 4: Run tests to verify pass**

Run:

```powershell
pytest tests/test_auto_buy_strategy.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

```powershell
git add src/binance_quant/auto_buy.py tests/test_auto_buy_strategy.py
git commit -m "feat: add auto buy scanner"
```

---

### Task 3: Add Auto-Buyer Storage Audit Tables

**Files:**
- Modify: `src/binance_quant/storage.py`
- Test: `tests/test_storage.py`

- [ ] **Step 1: Write failing storage tests**

Append to `tests/test_storage.py`:

```python
def test_storage_records_auto_buy_run_and_events(tmp_path):
    storage = Storage(tmp_path / "audit.sqlite3")
    storage.initialize()

    run_id = storage.record_auto_buy_run(
        symbol="BTCUSDT",
        status="protected",
        score=Decimal("4.5"),
        reason="selected",
        quote_amount=Decimal("5"),
    )
    event_id = storage.record_auto_buy_event(
        run_id,
        event_type="market_buy",
        symbol="BTCUSDT",
        client_order_id="bqab-buy",
        status="FILLED",
        raw={"orderId": 1, "signature": "redacted"},
    )

    assert run_id == 1
    assert event_id == 1
    [run] = storage.list_auto_buy_runs()
    assert run["symbol"] == "BTCUSDT"
    assert run["status"] == "protected"
    assert run["score"] == "4.5"
    [event] = storage.list_auto_buy_events(run_id)
    assert event["event_type"] == "market_buy"
    assert json.loads(event["raw_json"]) == {"orderId": 1, "signature": "redacted"}


def test_storage_counts_live_auto_buy_attempts_since(tmp_path):
    storage = Storage(tmp_path / "audit.sqlite3")
    storage.initialize()
    first = storage.record_auto_buy_run("BTCUSDT", "buy_submitted", Decimal("4"), "first", Decimal("5"), created_at_ms=1000)
    storage.record_auto_buy_run("ETHUSDT", "no_candidate", Decimal("0"), "none", Decimal("5"), created_at_ms=2000)
    storage.record_auto_buy_run("SOLUSDT", "rollback_completed", Decimal("5"), "rollback", Decimal("5"), created_at_ms=3000)

    assert first == 1
    assert storage.count_auto_buy_attempts_since(0) == 2
    assert storage.count_auto_buy_attempts_since(2000) == 1


def test_storage_finds_recent_auto_buy_attempt_for_symbol(tmp_path):
    storage = Storage(tmp_path / "audit.sqlite3")
    storage.initialize()
    storage.record_auto_buy_run("BTCUSDT", "protected", Decimal("4"), "old", Decimal("5"), created_at_ms=1000)
    storage.record_auto_buy_run("ETHUSDT", "protected", Decimal("4"), "other", Decimal("5"), created_at_ms=5000)

    assert storage.has_recent_auto_buy_attempt("BTCUSDT", since_ms=999)
    assert not storage.has_recent_auto_buy_attempt("BTCUSDT", since_ms=1001)
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_storage.py -q
```

Expected: FAIL because auto-buy storage methods do not exist.

- [ ] **Step 3: Implement audit schema and helpers**

In `Storage.initialize()`, add tables:

```sql
                CREATE TABLE IF NOT EXISTS auto_buy_runs (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    created_at_ms INTEGER NOT NULL,
                    symbol TEXT,
                    status TEXT NOT NULL,
                    score TEXT NOT NULL,
                    reason TEXT NOT NULL,
                    quote_amount TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS auto_buy_events (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    created_at_ms INTEGER NOT NULL,
                    run_id INTEGER NOT NULL,
                    event_type TEXT NOT NULL,
                    symbol TEXT,
                    client_order_id TEXT,
                    status TEXT,
                    raw_json TEXT NOT NULL,
                    FOREIGN KEY(run_id) REFERENCES auto_buy_runs(id)
                );
```

Add methods to `Storage`:

```python
    def record_auto_buy_run(
        self,
        symbol: str | None,
        status: str,
        score: Decimal,
        reason: str,
        quote_amount: Decimal,
        created_at_ms: int | None = None,
    ) -> int:
        with self._connect() as connection:
            cursor = connection.execute(
                """
                INSERT INTO auto_buy_runs (created_at_ms, symbol, status, score, reason, quote_amount)
                VALUES (?, ?, ?, ?, ?, ?)
                """,
                (created_at_ms or _now_ms(), symbol.upper() if symbol else None, status, str(score), reason, str(quote_amount)),
            )
            return int(cursor.lastrowid)

    def update_auto_buy_run_status(self, run_id: int, status: str, reason: str) -> None:
        with self._connect() as connection:
            connection.execute(
                "UPDATE auto_buy_runs SET status = ?, reason = ? WHERE id = ?",
                (status, reason, run_id),
            )

    def record_auto_buy_event(
        self,
        run_id: int,
        event_type: str,
        symbol: str | None,
        client_order_id: str | None,
        status: str | None,
        raw: dict,
        created_at_ms: int | None = None,
    ) -> int:
        with self._connect() as connection:
            cursor = connection.execute(
                """
                INSERT INTO auto_buy_events (created_at_ms, run_id, event_type, symbol, client_order_id, status, raw_json)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    created_at_ms or _now_ms(),
                    run_id,
                    event_type,
                    symbol.upper() if symbol else None,
                    client_order_id,
                    status,
                    json.dumps(raw, sort_keys=True, default=str),
                ),
            )
            return int(cursor.lastrowid)

    def list_auto_buy_runs(self, limit: int = 50) -> list[dict[str, Any]]:
        if limit < 0:
            raise ValueError("limit must be non-negative")
        with self._connect() as connection:
            rows = connection.execute("SELECT * FROM auto_buy_runs ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
            return [dict(row) for row in rows]

    def list_auto_buy_events(self, run_id: int) -> list[dict[str, Any]]:
        with self._connect() as connection:
            rows = connection.execute("SELECT * FROM auto_buy_events WHERE run_id = ? ORDER BY id", (run_id,)).fetchall()
            return [dict(row) for row in rows]

    def count_auto_buy_attempts_since(self, since_ms: int) -> int:
        with self._connect() as connection:
            row = connection.execute(
                """
                SELECT COUNT(*) AS count FROM auto_buy_runs
                WHERE created_at_ms >= ?
                AND status IN ('buy_submitted', 'protected', 'protection_failed', 'rollback_completed', 'rollback_failed')
                """,
                (since_ms,),
            ).fetchone()
            return int(row["count"])

    def has_recent_auto_buy_attempt(self, symbol: str, since_ms: int) -> bool:
        with self._connect() as connection:
            row = connection.execute(
                """
                SELECT 1 FROM auto_buy_runs
                WHERE symbol = ? AND created_at_ms >= ?
                AND status IN ('buy_submitted', 'protected', 'protection_failed', 'rollback_completed', 'rollback_failed')
                LIMIT 1
                """,
                (symbol.upper(), since_ms),
            ).fetchone()
            return row is not None
```

- [ ] **Step 4: Run tests to verify pass**

Run:

```powershell
pytest tests/test_storage.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

```powershell
git add src/binance_quant/storage.py tests/test_storage.py
git commit -m "feat: audit live auto buyer runs"
```

---

### Task 4: Add Live Auto-Buyer Risk Checks

**Files:**
- Modify: `src/binance_quant/auto_buy.py`
- Test: `tests/test_auto_buy_risk.py`

- [ ] **Step 1: Write failing risk tests**

Create `tests/test_auto_buy_risk.py`:

```python
from decimal import Decimal

from binance_quant.auto_buy import AutoBuyCandidate, AutoBuyRiskContext, AutoBuyRiskEngine, AutoBuyRiskSettings
from binance_quant.models import AccountSnapshot, Balance, SymbolMetadata, TradingMode


METADATA = SymbolMetadata("BTCUSDT", "BTC", "USDT", Decimal("5"), Decimal("0.00001"), Decimal("0.00001"), Decimal("0.01"), Decimal("0.01"))
CANDIDATE = AutoBuyCandidate("BTCUSDT", Decimal("4.5"), Decimal("50000"), Decimal("5"), ("test",))


def account(usdt: str = "20", btc: str = "0") -> AccountSnapshot:
    return AccountSnapshot({"USDT": Balance("USDT", Decimal(usdt)), "BTC": Balance("BTC", Decimal(btc))})


def context(**overrides) -> AutoBuyRiskContext:
    values = {
        "mode": TradingMode.LIVE,
        "enabled": True,
        "candidate": CANDIDATE,
        "metadata": METADATA,
        "account": account(),
        "daily_attempts": 0,
        "has_recent_symbol_attempt": False,
        "quote_amount": Decimal("5"),
    }
    values.update(overrides)
    return AutoBuyRiskContext(**values)


def test_auto_buy_risk_approves_valid_candidate():
    decision = AutoBuyRiskEngine(AutoBuyRiskSettings(daily_limit=5, quote_reserve=Decimal("0"))).evaluate(context())

    assert decision.approved
    assert decision.reason == "approved"


def test_auto_buy_risk_requires_live_mode_and_enable_flag():
    engine = AutoBuyRiskEngine(AutoBuyRiskSettings(daily_limit=5))

    assert engine.evaluate(context(mode=TradingMode.DRY_RUN)).reason == "live mode required"
    assert engine.evaluate(context(enabled=False)).reason == "live auto buyer disabled"


def test_auto_buy_risk_rejects_daily_cap_and_recent_symbol_attempt():
    engine = AutoBuyRiskEngine(AutoBuyRiskSettings(daily_limit=5))

    assert engine.evaluate(context(daily_attempts=5)).reason == "daily auto-buy limit reached"
    assert engine.evaluate(context(has_recent_symbol_attempt=True)).reason == "symbol cooldown active"


def test_auto_buy_risk_rejects_exchange_and_balance_limits():
    engine = AutoBuyRiskEngine(AutoBuyRiskSettings(daily_limit=5, quote_reserve=Decimal("1")))
    high_min_notional = SymbolMetadata("BTCUSDT", "BTC", "USDT", Decimal("10"), Decimal("0.00001"), Decimal("0.00001"), Decimal("0.01"), Decimal("0.01"))

    assert engine.evaluate(context(metadata=high_min_notional)).reason == "quote amount below min notional"
    assert engine.evaluate(context(account=account(usdt="5"))).reason == "insufficient quote balance"


def test_auto_buy_risk_rejects_existing_exposure_and_wide_spread():
    engine = AutoBuyRiskEngine(AutoBuyRiskSettings(daily_limit=5))
    wide = AutoBuyCandidate("BTCUSDT", Decimal("4.5"), Decimal("50000"), Decimal("20"), ("test",))

    assert engine.evaluate(context(account=account(btc="0.001"))).reason == "existing base balance"
    assert engine.evaluate(context(candidate=wide)).reason == "spread above max"
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_auto_buy_risk.py -q
```

Expected: FAIL because risk classes do not exist.

- [ ] **Step 3: Implement risk data classes and engine**

Add to `src/binance_quant/auto_buy.py`:

```python
@dataclass(frozen=True)
class AutoBuyRiskSettings:
    daily_limit: int
    max_spread_bps: Decimal = Decimal("10")
    quote_reserve: Decimal = Decimal("0")

    def __post_init__(self) -> None:
        if self.daily_limit <= 0:
            raise ValueError("daily_limit must be positive")
        if self.max_spread_bps <= 0:
            raise ValueError("max_spread_bps must be positive")
        if self.quote_reserve < 0:
            raise ValueError("quote_reserve must be non-negative")


@dataclass(frozen=True)
class AutoBuyRiskContext:
    mode: TradingMode
    enabled: bool
    candidate: AutoBuyCandidate
    metadata: SymbolMetadata
    account: AccountSnapshot
    daily_attempts: int
    has_recent_symbol_attempt: bool
    quote_amount: Decimal


@dataclass(frozen=True)
class AutoBuyRiskDecision:
    approved: bool
    reason: str
    candidate: AutoBuyCandidate


class AutoBuyRiskEngine:
    def __init__(self, settings: AutoBuyRiskSettings) -> None:
        self.settings = settings

    def evaluate(self, context: AutoBuyRiskContext) -> AutoBuyRiskDecision:
        candidate = context.candidate
        if context.mode is not TradingMode.LIVE:
            return AutoBuyRiskDecision(False, "live mode required", candidate)
        if not context.enabled:
            return AutoBuyRiskDecision(False, "live auto buyer disabled", candidate)
        if context.daily_attempts >= self.settings.daily_limit:
            return AutoBuyRiskDecision(False, "daily auto-buy limit reached", candidate)
        if context.has_recent_symbol_attempt:
            return AutoBuyRiskDecision(False, "symbol cooldown active", candidate)
        if candidate.symbol != context.metadata.symbol:
            return AutoBuyRiskDecision(False, "metadata symbol mismatch", candidate)
        if candidate.spread_bps > self.settings.max_spread_bps:
            return AutoBuyRiskDecision(False, "spread above max", candidate)
        if context.quote_amount < context.metadata.min_notional:
            return AutoBuyRiskDecision(False, "quote amount below min notional", candidate)
        if context.account.free(context.metadata.quote_asset) < context.quote_amount + self.settings.quote_reserve:
            return AutoBuyRiskDecision(False, "insufficient quote balance", candidate)
        if context.account.free(context.metadata.base_asset) > 0:
            return AutoBuyRiskDecision(False, "existing base balance", candidate)
        if context.metadata.step_size <= 0 or context.metadata.tick_size <= 0:
            return AutoBuyRiskDecision(False, "missing exchange filters", candidate)
        return AutoBuyRiskDecision(True, "approved", candidate)
```

- [ ] **Step 4: Run tests to verify pass**

Run:

```powershell
pytest tests/test_auto_buy_risk.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

```powershell
git add src/binance_quant/auto_buy.py tests/test_auto_buy_risk.py
git commit -m "feat: add auto buy risk checks"
```

---

### Task 5: Add Exchange Methods For Market Data And Protective Orders

**Files:**
- Modify: `src/binance_quant/exchange.py`
- Test: `tests/test_exchange.py`

- [ ] **Step 1: Write failing exchange method tests**

Append to `tests/test_exchange.py`:

```python
def test_symbol_order_book_ticker_returns_bid_and_ask():
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json={"symbol": "BTCUSDT", "bidPrice": "49999.9", "askPrice": "50000.1"})

    client = BinanceSpotClient(api_key="key", api_secret="secret", http_client=httpx.Client(transport=httpx.MockTransport(handler)))

    ticker = client.symbol_order_book_ticker("btcusdt")

    assert ticker == {"symbol": "BTCUSDT", "bidPrice": "49999.9", "askPrice": "50000.1"}


def test_klines_returns_payload():
    def handler(request: httpx.Request) -> httpx.Response:
        assert "symbol=BTCUSDT" in request.url.query.decode()
        assert "interval=1h" in request.url.query.decode()
        return httpx.Response(200, json=[[1, "1", "2", "0.5", "1.5", "10", 2]])

    client = BinanceSpotClient(api_key="key", api_secret="secret", http_client=httpx.Client(transport=httpx.MockTransport(handler)))

    assert client.klines("BTCUSDT", interval="1h", limit=6) == [[1, "1", "2", "0.5", "1.5", "10", 2]]


def test_open_orders_sends_signed_get_query():
    requests = []

    def handler(request: httpx.Request) -> httpx.Response:
        requests.append(request)
        return httpx.Response(200, json=[{"symbol": "BTCUSDT", "type": "STOP_LOSS_LIMIT"}])

    client = BinanceSpotClient(api_key="key", api_secret="secret", http_client=httpx.Client(transport=httpx.MockTransport(handler)))

    orders = client.open_orders("BTCUSDT", timestamp_ms=1)

    query = requests[0].url.query.decode()
    assert orders == [{"symbol": "BTCUSDT", "type": "STOP_LOSS_LIMIT"}]
    assert requests[0].method == "GET"
    assert "symbol=BTCUSDT" in query
    assert "signature=" in query


def test_create_market_sell_quantity_order_sends_quantity():
    requests = []

    def handler(request: httpx.Request) -> httpx.Response:
        requests.append(request)
        return httpx.Response(200, json={"status": "FILLED"})

    client = BinanceSpotClient(api_key="key", api_secret="secret", http_client=httpx.Client(transport=httpx.MockTransport(handler)))

    result = client.create_market_sell_quantity("BTCUSDT", Decimal("0.001"), "sell-1", timestamp_ms=1)

    body = requests[0].content.decode()
    assert result["status"] == "FILLED"
    assert "side=SELL" in body
    assert "type=MARKET" in body
    assert "quantity=0.001" in body
    assert "quoteOrderQty" not in body


def test_create_stop_loss_limit_sell_order_sends_stop_params():
    requests = []

    def handler(request: httpx.Request) -> httpx.Response:
        requests.append(request)
        return httpx.Response(200, json={"status": "NEW", "type": "STOP_LOSS_LIMIT"})

    client = BinanceSpotClient(api_key="key", api_secret="secret", http_client=httpx.Client(transport=httpx.MockTransport(handler)))

    result = client.create_stop_loss_limit_sell(
        "BTCUSDT",
        quantity=Decimal("0.001"),
        stop_price=Decimal("49000"),
        limit_price=Decimal("48900"),
        client_order_id="stop-1",
        timestamp_ms=1,
    )

    body = requests[0].content.decode()
    assert result["status"] == "NEW"
    assert "type=STOP_LOSS_LIMIT" in body
    assert "timeInForce=GTC" in body
    assert "stopPrice=49000" in body
    assert "price=48900" in body
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_exchange.py -q
```

Expected: FAIL because exchange methods do not exist.

- [ ] **Step 3: Implement exchange methods**

Add methods to `BinanceSpotClient` in `src/binance_quant/exchange.py`:

```python
    def symbol_order_book_ticker(self, symbol: str) -> dict:
        response = self._client.get(f"{self.base_url}/v3/ticker/bookTicker", params={"symbol": symbol.upper()})
        response.raise_for_status()
        return response.json()

    def klines(self, symbol: str, interval: str = "1h", limit: int = 6) -> list:
        response = self._client.get(
            f"{self.base_url}/v3/klines",
            params={"symbol": symbol.upper(), "interval": interval, "limit": limit},
        )
        response.raise_for_status()
        return response.json()

    def open_orders(self, symbol: str, timestamp_ms: int | None = None) -> list[dict]:
        payload = self._signed_request("GET", "/v3/openOrders", {"symbol": symbol.upper()}, timestamp_ms)
        return payload if isinstance(payload, list) else []

    def test_market_buy_order(
        self,
        symbol: str,
        quote_amount: Decimal,
        client_order_id: str,
        timestamp_ms: int | None = None,
    ) -> dict:
        params = {
            "symbol": symbol.upper(),
            "side": OrderSide.BUY.value,
            "type": "MARKET",
            "quoteOrderQty": format(quote_amount, "f"),
            "newClientOrderId": client_order_id,
        }
        return self._signed_request("POST", "/v3/order/test", params, timestamp_ms)

    def create_market_sell_quantity(
        self,
        symbol: str,
        quantity: Decimal,
        client_order_id: str,
        timestamp_ms: int | None = None,
    ) -> dict:
        params = {
            "symbol": symbol.upper(),
            "side": OrderSide.SELL.value,
            "type": "MARKET",
            "quantity": format(quantity, "f"),
            "newClientOrderId": client_order_id,
        }
        return self._signed_request("POST", "/v3/order", params, timestamp_ms)

    def create_stop_loss_limit_sell(
        self,
        symbol: str,
        quantity: Decimal,
        stop_price: Decimal,
        limit_price: Decimal,
        client_order_id: str,
        timestamp_ms: int | None = None,
    ) -> dict:
        params = {
            "symbol": symbol.upper(),
            "side": OrderSide.SELL.value,
            "type": "STOP_LOSS_LIMIT",
            "timeInForce": "GTC",
            "quantity": format(quantity, "f"),
            "stopPrice": format(stop_price, "f"),
            "price": format(limit_price, "f"),
            "newClientOrderId": client_order_id,
        }
        return self._signed_request("POST", "/v3/order", params, timestamp_ms)
```

- [ ] **Step 4: Run tests to verify pass**

Run:

```powershell
pytest tests/test_exchange.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

```powershell
git add src/binance_quant/exchange.py tests/test_exchange.py
git commit -m "feat: add spot auto buy exchange methods"
```

---

### Task 6: Add Two-Phase Execution With Stop-Loss Rollback

**Files:**
- Modify: `src/binance_quant/auto_buy.py`
- Test: `tests/test_auto_buy_execution.py`

- [ ] **Step 1: Write failing execution tests**

Create `tests/test_auto_buy_execution.py`:

```python
from decimal import Decimal

import pytest

from binance_quant.auto_buy import AutoBuyCandidate, AutoBuyExecutionEngine
from binance_quant.models import SymbolMetadata
from binance_quant.storage import Storage


METADATA = SymbolMetadata("BTCUSDT", "BTC", "USDT", Decimal("5"), Decimal("0.00001"), Decimal("0.00001"), Decimal("0.01"), Decimal("0.01"))
CANDIDATE = AutoBuyCandidate("BTCUSDT", Decimal("4.5"), Decimal("50000"), Decimal("5"), ("test",))


class FakeExchange:
    def __init__(self, stop_fails: bool = False, rollback_fails: bool = False):
        self.stop_fails = stop_fails
        self.rollback_fails = rollback_fails
        self.calls = []

    def create_market_order(self, symbol, side, quote_amount, client_order_id):
        self.calls.append(("buy", symbol, side.value, quote_amount, client_order_id))
        return {
            "symbol": symbol,
            "status": "FILLED",
            "clientOrderId": client_order_id,
            "executedQty": "0.001",
            "cummulativeQuoteQty": "50",
        }

    def create_stop_loss_limit_sell(self, symbol, quantity, stop_price, limit_price, client_order_id):
        self.calls.append(("stop", symbol, quantity, stop_price, limit_price, client_order_id))
        if self.stop_fails:
            raise RuntimeError("stop failed")
        return {"symbol": symbol, "status": "NEW", "clientOrderId": client_order_id, "type": "STOP_LOSS_LIMIT"}

    def create_market_sell_quantity(self, symbol, quantity, client_order_id):
        self.calls.append(("rollback", symbol, quantity, client_order_id))
        if self.rollback_fails:
            raise RuntimeError("rollback failed")
        return {"symbol": symbol, "status": "FILLED", "clientOrderId": client_order_id}


def test_execution_buys_then_places_stop_loss(tmp_path):
    storage = Storage(tmp_path / "audit.sqlite3")
    storage.initialize()
    exchange = FakeExchange()
    engine = AutoBuyExecutionEngine(exchange, storage, stop_loss_percent=Decimal("2"), stop_limit_buffer_percent=Decimal("0.2"))

    result = engine.execute(CANDIDATE, METADATA, quote_amount=Decimal("5"))

    assert result.status == "protected"
    assert exchange.calls[0][0] == "buy"
    assert exchange.calls[1][0] == "stop"
    assert exchange.calls[1][3] == Decimal("49000.00")
    assert exchange.calls[1][4] == Decimal("48902.00")
    [run] = storage.list_auto_buy_runs()
    assert run["status"] == "protected"


def test_execution_rolls_back_when_stop_fails(tmp_path):
    storage = Storage(tmp_path / "audit.sqlite3")
    storage.initialize()
    exchange = FakeExchange(stop_fails=True)
    engine = AutoBuyExecutionEngine(exchange, storage, stop_loss_percent=Decimal("2"), stop_limit_buffer_percent=Decimal("0.2"))

    result = engine.execute(CANDIDATE, METADATA, quote_amount=Decimal("5"))

    assert result.status == "rollback_completed"
    assert [call[0] for call in exchange.calls] == ["buy", "stop", "rollback"]
    [run] = storage.list_auto_buy_runs()
    assert run["status"] == "rollback_completed"


def test_execution_records_critical_status_when_rollback_fails(tmp_path):
    storage = Storage(tmp_path / "audit.sqlite3")
    storage.initialize()
    exchange = FakeExchange(stop_fails=True, rollback_fails=True)
    engine = AutoBuyExecutionEngine(exchange, storage, stop_loss_percent=Decimal("2"), stop_limit_buffer_percent=Decimal("0.2"))

    result = engine.execute(CANDIDATE, METADATA, quote_amount=Decimal("5"))

    assert result.status == "rollback_failed"
    [run] = storage.list_auto_buy_runs()
    assert run["status"] == "rollback_failed"


def test_execution_records_critical_status_when_rounded_quantity_is_below_min_qty(tmp_path):
    storage = Storage(tmp_path / "audit.sqlite3")
    storage.initialize()
    exchange = FakeExchange()
    high_min_qty = SymbolMetadata("BTCUSDT", "BTC", "USDT", Decimal("5"), Decimal("0.01"), Decimal("0.00001"), Decimal("0.01"), Decimal("0.01"))
    engine = AutoBuyExecutionEngine(exchange, storage, stop_loss_percent=Decimal("2"), stop_limit_buffer_percent=Decimal("0.2"))

    result = engine.execute(CANDIDATE, high_min_qty, quote_amount=Decimal("5"))

    assert result.status == "rollback_failed"
    assert [call[0] for call in exchange.calls] == ["buy"]
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_auto_buy_execution.py -q
```

Expected: FAIL because `AutoBuyExecutionEngine` does not exist.

- [ ] **Step 3: Implement execution workflow**

Add to `src/binance_quant/auto_buy.py`:

```python
from .execution import build_client_order_id
from .models import OrderSide
from .storage import Storage


@dataclass(frozen=True)
class AutoBuyExecutionResult:
    run_id: int
    status: str
    symbol: str
    reason: str


def _round_down(value: Decimal, increment: Decimal) -> Decimal:
    if increment <= 0:
        raise ValueError("increment must be positive")
    return (value / increment).to_integral_value(rounding=ROUND_DOWN) * increment


def _average_fill_price(payload: dict) -> Decimal:
    executed_qty = Decimal(str(payload.get("executedQty", "0")))
    quote_qty = Decimal(str(payload.get("cummulativeQuoteQty", "0")))
    if executed_qty <= 0 or quote_qty <= 0:
        raise ValueError("market buy response missing executed quantity")
    return quote_qty / executed_qty


class AutoBuyExecutionEngine:
    def __init__(
        self,
        exchange,
        storage: Storage,
        stop_loss_percent: Decimal,
        stop_limit_buffer_percent: Decimal,
    ) -> None:
        self.exchange = exchange
        self.storage = storage
        self.stop_loss_percent = stop_loss_percent
        self.stop_limit_buffer_percent = stop_limit_buffer_percent

    def execute(self, candidate: AutoBuyCandidate, metadata: SymbolMetadata, quote_amount: Decimal) -> AutoBuyExecutionResult:
        run_id = self.storage.record_auto_buy_run(
            candidate.symbol,
            "buy_submitted",
            candidate.score,
            "; ".join(candidate.reasons),
            quote_amount,
        )
        buy_client_order_id = build_client_order_id("bqab-buy")
        buy_payload = self.exchange.create_market_order(candidate.symbol, OrderSide.BUY, quote_amount, buy_client_order_id)
        self.storage.record_auto_buy_event(run_id, "market_buy", candidate.symbol, buy_client_order_id, buy_payload.get("status"), buy_payload)

        executed_qty = _round_down(Decimal(str(buy_payload["executedQty"])), metadata.step_size)
        average_price = _average_fill_price(buy_payload)
        if executed_qty < metadata.min_qty:
            self.storage.record_auto_buy_event(
                run_id,
                "rollback_failed",
                candidate.symbol,
                None,
                "FAILED",
                {"message": "executed quantity below minimum sell quantity"},
            )
            self.storage.update_auto_buy_run_status(run_id, "rollback_failed", "executed quantity below minimum sell quantity")
            return AutoBuyExecutionResult(run_id, "rollback_failed", candidate.symbol, "executed quantity below minimum sell quantity")
        stop_price = _round_down(average_price * (Decimal("1") - self.stop_loss_percent / Decimal("100")), metadata.tick_size)
        limit_price = _round_down(stop_price * (Decimal("1") - self.stop_limit_buffer_percent / Decimal("100")), metadata.tick_size)
        stop_client_order_id = build_client_order_id("bqab-stop")

        try:
            stop_payload = self.exchange.create_stop_loss_limit_sell(
                candidate.symbol,
                executed_qty,
                stop_price,
                limit_price,
                stop_client_order_id,
            )
        except Exception as exc:
            self.storage.record_auto_buy_event(
                run_id,
                "protective_stop_failed",
                candidate.symbol,
                stop_client_order_id,
                "FAILED",
                {"error_type": type(exc).__name__, "message": "protective stop failed"},
            )
            return self._rollback(run_id, candidate.symbol, executed_qty)

        self.storage.record_auto_buy_event(run_id, "protective_stop", candidate.symbol, stop_client_order_id, stop_payload.get("status"), stop_payload)
        self.storage.update_auto_buy_run_status(run_id, "protected", "protective stop accepted")
        return AutoBuyExecutionResult(run_id, "protected", candidate.symbol, "protective stop accepted")

    def _rollback(self, run_id: int, symbol: str, quantity: Decimal) -> AutoBuyExecutionResult:
        rollback_client_order_id = build_client_order_id("bqab-rb")
        try:
            rollback_payload = self.exchange.create_market_sell_quantity(symbol, quantity, rollback_client_order_id)
        except Exception as exc:
            self.storage.record_auto_buy_event(
                run_id,
                "rollback_failed",
                symbol,
                rollback_client_order_id,
                "FAILED",
                {"error_type": type(exc).__name__, "message": "rollback sell failed"},
            )
            self.storage.update_auto_buy_run_status(run_id, "rollback_failed", "protective stop and rollback failed")
            return AutoBuyExecutionResult(run_id, "rollback_failed", symbol, "protective stop and rollback failed")

        self.storage.record_auto_buy_event(run_id, "rollback_sell", symbol, rollback_client_order_id, rollback_payload.get("status"), rollback_payload)
        self.storage.update_auto_buy_run_status(run_id, "rollback_completed", "protective stop failed; rollback sell submitted")
        return AutoBuyExecutionResult(run_id, "rollback_completed", symbol, "protective stop failed; rollback sell submitted")
```

- [ ] **Step 4: Run tests to verify pass**

Run:

```powershell
pytest tests/test_auto_buy_execution.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

```powershell
git add src/binance_quant/auto_buy.py tests/test_auto_buy_execution.py
git commit -m "feat: execute protected auto buys"
```

---

### Task 7: Add Single-Run Orchestration

**Files:**
- Modify: `src/binance_quant/auto_buy.py`
- Test: `tests/test_auto_buy_runner.py`

- [ ] **Step 1: Write failing runner tests**

Create `tests/test_auto_buy_runner.py`:

```python
from decimal import Decimal
from pathlib import Path

from pydantic import SecretStr

from binance_quant.auto_buy import run_live_auto_buy_once
from binance_quant.config import Settings
from binance_quant.models import AccountSnapshot, Balance, SymbolMetadata, TradingMode


class FakeExchange:
    def __init__(self):
        self.bought = False

    def exchange_info(self, symbol):
        return SymbolMetadata(symbol, symbol.replace("USDT", ""), "USDT", Decimal("5"), Decimal("0.00001"), Decimal("0.00001"), Decimal("0.01"), Decimal("0.01"))

    def account(self):
        return AccountSnapshot({"USDT": Balance("USDT", Decimal("20")), "BTC": Balance("BTC", Decimal("0")), "ETH": Balance("ETH", Decimal("0"))})

    def symbol_order_book_ticker(self, symbol):
        return {"symbol": symbol, "bidPrice": "104.9", "askPrice": "105"}

    def klines(self, symbol, interval="1h", limit=6):
        return [
            [1, "100", "100", "100", "100", "10", 2],
            [2, "101", "101", "101", "101", "10", 3],
            [3, "102", "102", "102", "102", "10", 4],
            [4, "103", "103", "103", "103", "10", 5],
            [5, "104", "104", "104", "104", "10", 6],
            [6, "105", "105", "105", "105", "12", 7],
        ]

    def open_orders(self, symbol):
        return []

    def create_market_order(self, symbol, side, quote_amount, client_order_id):
        self.bought = True
        return {"symbol": symbol, "status": "FILLED", "clientOrderId": client_order_id, "executedQty": "0.001", "cummulativeQuoteQty": "50"}

    def create_stop_loss_limit_sell(self, symbol, quantity, stop_price, limit_price, client_order_id):
        return {"symbol": symbol, "status": "NEW", "clientOrderId": client_order_id}

    def create_market_sell_quantity(self, symbol, quantity, client_order_id):
        return {"symbol": symbol, "status": "FILLED", "clientOrderId": client_order_id}


def settings(tmp_path: Path, enabled: bool = True) -> Settings:
    return Settings(
        _env_file=None,
        mode=TradingMode.LIVE,
        api_key="key",
        api_secret=SecretStr("secret"),
        database_path=tmp_path / "audit.sqlite3",
        live_auto_buyer_enabled=enabled,
        auto_buy_symbols=("BTCUSDT",),
        auto_buy_min_score=Decimal("4"),
    )


def test_run_live_auto_buy_once_places_protected_buy(tmp_path):
    exchange = FakeExchange()

    result = run_live_auto_buy_once(settings(tmp_path), exchange_client=exchange)

    assert result.status == "protected"
    assert result.symbol == "BTCUSDT"
    assert exchange.bought


def test_run_live_auto_buy_once_records_disabled_rejection(tmp_path):
    exchange = FakeExchange()

    result = run_live_auto_buy_once(settings(tmp_path, enabled=False), exchange_client=exchange)

    assert result.status == "rejected"
    assert result.reason == "live auto buyer disabled"
    assert not exchange.bought
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_auto_buy_runner.py -q
```

Expected: FAIL because `run_live_auto_buy_once` does not exist.

- [ ] **Step 3: Implement orchestration**

Add to `src/binance_quant/auto_buy.py`:

```python
import time
from datetime import datetime

from .config import Settings
from .exchange import BinanceSpotClient


def _kline_to_candle(symbol: str, row: list) -> Candle:
    return Candle(
        symbol=symbol,
        open_time=int(row[0]),
        close_time=int(row[6]),
        open=Decimal(str(row[1])),
        high=Decimal(str(row[2])),
        low=Decimal(str(row[3])),
        close=Decimal(str(row[4])),
        volume=Decimal(str(row[5])),
    )


def _local_day_start_ms(now_ms: int) -> int:
    current = datetime.fromtimestamp(now_ms / 1000)
    start = current.replace(hour=0, minute=0, second=0, microsecond=0)
    return int(start.timestamp() * 1000)


def run_live_auto_buy_once(settings: Settings, exchange_client=None, now_ms: int | None = None) -> AutoBuyExecutionResult:
    storage = Storage(settings.database_path)
    storage.initialize()
    now_ms = now_ms if now_ms is not None else int(time.time() * 1000)
    if settings.mode is not TradingMode.LIVE:
        run_id = storage.record_auto_buy_run(None, "rejected", Decimal("0"), "live mode required", settings.auto_buy_quote_amount)
        return AutoBuyExecutionResult(run_id, "rejected", "", "live mode required")
    if not settings.live_auto_buyer_enabled:
        run_id = storage.record_auto_buy_run(None, "rejected", Decimal("0"), "live auto buyer disabled", settings.auto_buy_quote_amount)
        return AutoBuyExecutionResult(run_id, "rejected", "", "live auto buyer disabled")
    exchange = exchange_client or BinanceSpotClient(
        api_key=settings.api_key,
        api_secret=settings.api_secret.get_secret_value(),
        base_url=settings.rest_base_url,
        recv_window=settings.recv_window,
    )

    snapshots: list[AutoBuyMarketSnapshot] = []
    metadata_by_symbol: dict[str, SymbolMetadata] = {}
    for symbol in settings.auto_buy_symbols:
        metadata_by_symbol[symbol] = exchange.exchange_info(symbol)
        book = exchange.symbol_order_book_ticker(symbol)
        rows = exchange.klines(symbol, interval="1h", limit=6)
        snapshots.append(
            AutoBuyMarketSnapshot(
                symbol=symbol,
                bid=Decimal(str(book["bidPrice"])),
                ask=Decimal(str(book["askPrice"])),
                candles=[_kline_to_candle(symbol, row) for row in rows],
            )
        )

    scanner = AutoBuyScanner(settings.auto_buy_symbols, settings.auto_buy_min_score, settings.auto_buy_max_spread_bps)
    candidate = scanner.select(snapshots)
    if candidate is None:
        run_id = storage.record_auto_buy_run(None, "no_candidate", Decimal("0"), "no candidate passed scanner", settings.auto_buy_quote_amount)
        return AutoBuyExecutionResult(run_id, "no_candidate", "", "no candidate passed scanner")

    metadata = metadata_by_symbol[candidate.symbol]
    account = exchange.account()
    day_start_ms = _local_day_start_ms(now_ms)
    recent_since_ms = now_ms - settings.auto_buy_cooldown_seconds * 1000
    risk = AutoBuyRiskEngine(
        AutoBuyRiskSettings(
            daily_limit=settings.auto_buy_daily_limit,
            max_spread_bps=settings.auto_buy_max_spread_bps,
        )
    )
    decision = risk.evaluate(
        AutoBuyRiskContext(
            mode=settings.mode,
            enabled=settings.live_auto_buyer_enabled,
            candidate=candidate,
            metadata=metadata,
            account=account,
            daily_attempts=storage.count_auto_buy_attempts_since(day_start_ms),
            has_recent_symbol_attempt=storage.has_recent_auto_buy_attempt(candidate.symbol, recent_since_ms),
            quote_amount=settings.auto_buy_quote_amount,
        )
    )
    if not decision.approved:
        run_id = storage.record_auto_buy_run(candidate.symbol, "rejected", candidate.score, decision.reason, settings.auto_buy_quote_amount)
        return AutoBuyExecutionResult(run_id, "rejected", candidate.symbol, decision.reason)

    return AutoBuyExecutionEngine(
        exchange,
        storage,
        settings.auto_buy_stop_loss_percent,
        settings.auto_buy_stop_limit_buffer_percent,
    ).execute(candidate, metadata, settings.auto_buy_quote_amount)
```

- [ ] **Step 4: Run tests to verify pass**

Run:

```powershell
pytest tests/test_auto_buy_runner.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

```powershell
git add src/binance_quant/auto_buy.py tests/test_auto_buy_runner.py
git commit -m "feat: orchestrate live auto buy run"
```

---

### Task 8: Add CLI Command

**Files:**
- Modify: `src/binance_quant/cli.py`
- Test: `tests/test_cli.py`

- [ ] **Step 1: Write failing CLI tests**

Append to `tests/test_cli.py`:

```python
def test_live_auto_buy_once_command_prints_result(tmp_path, monkeypatch):
    config_path = tmp_path / "config.yml"
    db_path = tmp_path / "audit.sqlite3"
    config_path.write_text(
        f"mode: live\napi_key: key\napi_secret: secret\ndatabase_path: {db_path}\nlive_auto_buyer_enabled: false\n",
        encoding="utf-8",
    )

    class Result:
        run_id = 7
        status = "rejected"
        symbol = "BTCUSDT"
        reason = "live auto buyer disabled"

    monkeypatch.setattr("binance_quant.cli.run_live_auto_buy_once", lambda settings: Result())

    result = runner.invoke(app, ["live-auto-buy-once", "--config", str(config_path)])

    assert result.exit_code == 0
    assert "Run ID: 7" in result.stdout
    assert "Status: rejected" in result.stdout
    assert "Symbol: BTCUSDT" in result.stdout
    assert "Reason: live auto buyer disabled" in result.stdout


def test_live_auto_buy_once_command_sanitizes_http_errors(tmp_path, monkeypatch):
    config_path = tmp_path / "config.yml"
    config_path.write_text("mode: live\napi_key: key\napi_secret: secret\n", encoding="utf-8")

    def fail(settings):
        request = httpx.Request("POST", "https://api.binance.com/api/v3/order?timestamp=1&signature=secret")
        response = httpx.Response(400, request=request)
        raise httpx.HTTPStatusError("signed request failed", request=request, response=response)

    monkeypatch.setattr("binance_quant.cli.run_live_auto_buy_once", fail)

    result = runner.invoke(app, ["live-auto-buy-once", "--config", str(config_path)])

    assert result.exit_code != 0
    assert "Binance API request failed" in result.output
    assert "signature=" not in result.output
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_cli.py -q
```

Expected: FAIL because command/import does not exist.

- [ ] **Step 3: Implement CLI command**

In `src/binance_quant/cli.py`, add import:

```python
from .auto_buy import run_live_auto_buy_once
```

Add command:

```python
@app.command("live-auto-buy-once")
def live_auto_buy_once(config: Path | None = _config_option()) -> None:
    settings = load_settings(config)
    try:
        result = run_live_auto_buy_once(settings)
    except httpx.HTTPError:
        _exit_for_http_error()
    console.print(f"Run ID: {result.run_id}")
    console.print(f"Status: {result.status}")
    console.print(f"Symbol: {result.symbol or '-'}")
    console.print(f"Reason: {result.reason}")
```

- [ ] **Step 4: Run tests to verify pass**

Run:

```powershell
pytest tests/test_cli.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

```powershell
git add src/binance_quant/cli.py tests/test_cli.py
git commit -m "feat: add live auto buy CLI"
```

---

### Task 9: Add Script And Documentation

**Files:**
- Modify: `scripts/run.ps1`
- Modify: `tests/test_run_script.py`
- Modify: `.env.example`
- Modify: `README.md`

- [ ] **Step 1: Write failing run-script tests**

Append to `tests/test_run_script.py`:

```python
def test_run_script_has_live_auto_buy_once_action():
    text = RUN_SCRIPT.read_text(encoding="utf-8")

    assert '"live-auto-buy-once"' in text
    assert "binance-quant live-auto-buy-once" in text
    assert "Refusing live auto buy because BINANCE_MODE" in text


def test_readme_documents_live_auto_buy_disabled_default():
    readme = README.read_text(encoding="utf-8")

    assert "live-auto-buy-once" in readme
    assert "BINANCE_LIVE_AUTO_BUYER_ENABLED=false" in readme
    assert "5 USDT" in readme
    assert "2% stop" in readme
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
pytest tests/test_run_script.py -q
```

Expected: FAIL because script/docs are not updated.

- [ ] **Step 3: Update `.env.example`**

Add:

```text
BINANCE_LIVE_AUTO_BUYER_ENABLED=false
BINANCE_AUTO_BUY_SYMBOLS=BTCUSDT,ETHUSDT,BNBUSDT,SOLUSDT
BINANCE_AUTO_BUY_QUOTE_AMOUNT=5
BINANCE_AUTO_BUY_DAILY_LIMIT=5
BINANCE_AUTO_BUY_STOP_LOSS_PERCENT=2
BINANCE_AUTO_BUY_STOP_LIMIT_BUFFER_PERCENT=0.2
BINANCE_AUTO_BUY_MAX_SPREAD_BPS=10
BINANCE_AUTO_BUY_COOLDOWN_SECONDS=3600
BINANCE_AUTO_BUY_MIN_SCORE=4
```

- [ ] **Step 4: Update `scripts/run.ps1`**

Add `"live-auto-buy-once"` to the `ValidateSet`. Add this switch case:

```powershell
    "live-auto-buy-once" {
        Invoke-Python @"
from binance_quant.config import load_settings
from binance_quant.models import TradingMode

settings = load_settings()
if settings.mode is not TradingMode.LIVE:
    raise SystemExit(f"Refusing live auto buy because BINANCE_MODE is {settings.mode.value}. Set BINANCE_MODE=live.")
if not settings.live_auto_buyer_enabled:
    raise SystemExit("Refusing live auto buy because BINANCE_LIVE_AUTO_BUYER_ENABLED is false.")
"@
        binance-quant live-auto-buy-once
    }
```

- [ ] **Step 5: Update `README.md`**

Add a section:

```markdown
## Live Auto Buyer

`binance-quant live-auto-buy-once` performs one bounded production Spot auto-buy run. It scans `BTCUSDT`, `ETHUSDT`, `BNBUSDT`, and `SOLUSDT`, buys at most 5 USDT when deterministic rules pass, and places a fixed 2% stop. If stop placement fails after a buy, it immediately submits a market sell rollback.

The feature is disabled by default:

```text
BINANCE_LIVE_AUTO_BUYER_ENABLED=false
```

Only enable it after rotating any exposed API key, confirming `BINANCE_MODE=live`, and accepting that it can submit real Binance Spot orders without manual confirmation. The command performs at most one buy attempt per run and relies on SQLite audit records for the daily five-trade cap.
```
```

- [ ] **Step 6: Run tests to verify pass**

Run:

```powershell
pytest tests/test_run_script.py -q
```

Expected: PASS.

- [ ] **Step 7: Commit**

```powershell
git add scripts/run.ps1 tests/test_run_script.py .env.example README.md
git commit -m "docs: document live auto buyer command"
```

---

### Task 10: Configure Codex Automation To Run CLI

**Files:**
- No repository file changes required.

- [ ] **Step 1: Replace the existing analysis-only automation**

Use the Codex automation tool to update automation ID `binance` so its prompt says:

```text
Run `binance-quant live-auto-buy-once` in the workspace and report the command output. Do not choose trades in the prompt, do not place orders outside the CLI, do not read or print API secrets, and stop if the command exits non-zero.
```

Keep an hourly schedule. Keep cwd as `E:\GitStore\binance`.

- [ ] **Step 2: Verify automation state**

View automation ID `binance` and confirm:

- It runs hourly.
- It points at `E:\GitStore\binance`.
- Its prompt delegates trading logic only to `binance-quant live-auto-buy-once`.
- It does not include market-prediction instructions.

Expected: automation is active but cannot trade unless `BINANCE_LIVE_AUTO_BUYER_ENABLED=true` and valid live keys are configured.

---

### Task 11: Full Verification And Safety Review

**Files:**
- All modified files.

- [ ] **Step 1: Run focused auto-buyer tests**

Run:

```powershell
pytest tests/test_auto_buy_strategy.py tests/test_auto_buy_risk.py tests/test_auto_buy_execution.py tests/test_auto_buy_runner.py -q
```

Expected: PASS.

- [ ] **Step 2: Run full test suite**

Run:

```powershell
pytest -q
```

Expected: PASS.

- [ ] **Step 3: Run disabled live command smoke test**

Run with a temporary config that uses live mode but leaves auto-buyer disabled:

```powershell
$tmp = New-TemporaryFile
Set-Content -Path $tmp -Value "mode: live`napi_key: key`napi_secret: secret`nlive_auto_buyer_enabled: false`n"
binance-quant live-auto-buy-once --config $tmp
Remove-Item $tmp
```

Expected: command exits without submitting an order and prints `Status: rejected` with `Reason: live auto buyer disabled`. If the command attempts network calls before the enable check is fixed, adjust `run_live_auto_buy_once` so it short-circuits disabled mode before fetching exchange data.

- [ ] **Step 4: Review diff for secret safety**

Run:

```powershell
git diff -- . ":(exclude).env"
rg -n "API_KEY|API_SECRET|signature=|M32S|bPsE" .
```

Expected: no credentials or signed request URLs appear in tracked source, docs, tests, or new plan/spec files. If `.env` has exposed credentials, rotate the Binance key before enabling live auto-buyer.

- [ ] **Step 5: Commit final verification adjustments**

If Step 3 or Step 4 required code/doc changes, commit them:

```powershell
git add src tests scripts README.md .env.example
git commit -m "fix: harden live auto buyer safety"
```

If no changes were required, do not create an empty commit.
