# Binance Spot Testnet Trading System 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 Python CLI trading system that can backtest a simple Spot strategy, dry-run it, and submit market orders to Binance Spot Testnet.

**Architecture:** The project is a small Python package named `binance_quant` with typed models, isolated exchange adapters, reusable strategy/risk/execution modules, SQLite persistence, and a Typer CLI. Network code is isolated behind interfaces so most behavior is unit tested without live Binance calls.

**Tech Stack:** Python 3.11+, pytest, pydantic-settings, httpx, websockets, typer, rich, PyYAML, SQLite via stdlib `sqlite3`.

---

## Preconditions

- Current directory: `E:\GitStore\binance`.
- The directory was not a git repository when the design was written. Initialize git before implementation if the user has not already done so.
- Use Binance Spot Testnet only:
  - REST base URL: `https://testnet.binance.vision/api`
  - WebSocket stream URL: `wss://stream.testnet.binance.vision/ws`
- Do not log `BINANCE_API_SECRET` or signed query strings containing sensitive parameters.

## File Structure

- Create `pyproject.toml`: package metadata, runtime dependencies, pytest config, console script.
- Create `README.md`: setup, Testnet key configuration, commands, safety notes.
- Create `.env.example`: non-secret sample environment variables.
- Create `.gitignore`: Python, virtualenv, SQLite, `.env`, logs.
- Create `src/binance_quant/__init__.py`: package marker.
- Create `src/binance_quant/models.py`: shared dataclasses/enums for candles, signals, risk decisions, orders, account balances, symbol metadata.
- Create `src/binance_quant/config.py`: settings loading from env and YAML.
- Create `src/binance_quant/strategy.py`: SMA crossover strategy.
- Create `src/binance_quant/risk.py`: risk engine.
- Create `src/binance_quant/storage.py`: SQLite schema and repository functions.
- Create `src/binance_quant/exchange.py`: Binance REST signing and Testnet client.
- Create `src/binance_quant/execution.py`: dry-run and Testnet market order execution.
- Create `src/binance_quant/market_data.py`: CSV candle loading and WebSocket kline streaming.
- Create `src/binance_quant/backtest.py`: backtest runner using strategy, risk, and simulated execution.
- Create `src/binance_quant/runner.py`: live trading loop orchestration for dry-run and Testnet live modes.
- Create `src/binance_quant/cli.py`: Typer commands.
- Create `tests/`: unit tests for each module.

## Task 1: Project Scaffold

**Files:**
- Create: `pyproject.toml`
- Create: `.gitignore`
- Create: `.env.example`
- Create: `README.md`
- Create: `src/binance_quant/__init__.py`
- Create: `src/binance_quant/cli.py`
- Test: `tests/test_project_scaffold.py`

- [ ] **Step 1: Initialize git if needed**

Run:

```powershell
git rev-parse --is-inside-work-tree
```

Expected if not initialized: `fatal: not a git repository`.

Then run:

```powershell
git init
```

Expected: repository initialized.

- [ ] **Step 2: Create package directories**

Run:

```powershell
New-Item -ItemType Directory -Force -Path 'src/binance_quant','tests' | Out-Null
```

Expected: directories exist.

- [ ] **Step 3: Write the failing scaffold test**

Create `tests/test_project_scaffold.py`:

```python
from importlib.metadata import version


def test_package_imports():
    import binance_quant

    assert binance_quant.__version__ == version("binance-quant")


def test_console_entrypoint_target_resolves():
    from importlib.metadata import entry_points

    from binance_quant.cli import app

    scripts = entry_points(group="console_scripts")
    entrypoint = next(item for item in scripts if item.name == "binance-quant")

    assert entrypoint.load() is app
```

- [ ] **Step 4: Run test to verify it fails**

Run:

```powershell
python -m pytest tests/test_project_scaffold.py -q
```

Expected: FAIL because pytest or the package metadata is not configured yet.

- [ ] **Step 5: Write minimal scaffold**

Create `pyproject.toml`:

```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "binance-quant"
version = "0.1.0"
description = "Binance Spot Testnet quantitative trading system"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
  "httpx>=0.27",
  "pydantic>=2.8",
  "pydantic-settings>=2.4",
  "PyYAML>=6.0",
  "typer>=0.12",
  "rich>=13.7",
  "websockets>=12.0",
]

[project.optional-dependencies]
dev = [
  "pytest>=8.2",
]

[project.scripts]
binance-quant = "binance_quant.cli:app"

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-q"
```

Create `src/binance_quant/__init__.py`:

```python
__version__ = "0.1.0"
```

Create `src/binance_quant/cli.py`:

```python
from __future__ import annotations

import typer

app = typer.Typer(no_args_is_help=True)
```

Create `.gitignore`:

```gitignore
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
*.db
*.sqlite
*.sqlite3
logs/
dist/
build/
*.egg-info/
```

Create `.env.example`:

```dotenv
BINANCE_API_KEY=
BINANCE_API_SECRET=
BINANCE_MODE=dry_run
BINANCE_SYMBOL=BTCUSDT
BINANCE_QUOTE_AMOUNT=25
BINANCE_MAX_QUOTE_AMOUNT=50
BINANCE_FAST_WINDOW=5
BINANCE_SLOW_WINDOW=20
BINANCE_COOLDOWN_SECONDS=60
BINANCE_DATABASE_PATH=binance_quant.sqlite3
BINANCE_LOG_LEVEL=INFO
```

Create `README.md`:

```markdown
# Binance Quant

Python CLI trading system for Binance Spot Testnet.

The first version supports dry-run mode, Spot Testnet market orders, SQLite audit storage, and an SMA crossover strategy. Real-money trading is not enabled.
```

- [ ] **Step 6: Install editable package**

Run:

```powershell
python -m pip install -e ".[dev]"
```

Expected: dependencies installed and package editable.

- [ ] **Step 7: Run test to verify it passes**

Run:

```powershell
python -m pytest tests/test_project_scaffold.py -q
```

Expected: PASS.

- [ ] **Step 8: Commit**

Run:

```powershell
git add pyproject.toml .gitignore .env.example README.md src/binance_quant/__init__.py src/binance_quant/cli.py tests/test_project_scaffold.py
git commit -m "chore: scaffold python trading project"
```

Expected: commit created.

## Task 2: Shared Models and Settings

**Files:**
- Create: `src/binance_quant/models.py`
- Create: `src/binance_quant/config.py`
- Test: `tests/test_config.py`
- Test: `tests/test_models.py`

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

Create `tests/test_models.py`:

```python
from decimal import Decimal

from binance_quant.models import Candle, OrderSide, Signal, SignalType, SymbolMetadata


def test_signal_uses_uppercase_side_and_symbol():
    signal = Signal(
        symbol="btcusdt",
        signal_type=SignalType.BUY,
        side=OrderSide.BUY,
        reason="fast SMA crossed above slow SMA",
    )

    assert signal.symbol == "BTCUSDT"
    assert signal.side is OrderSide.BUY


def test_symbol_metadata_extracts_assets_from_symbol():
    metadata = SymbolMetadata(
        symbol="BTCUSDT",
        base_asset="BTC",
        quote_asset="USDT",
        min_notional=Decimal("5"),
        min_qty=Decimal("0.00001"),
        step_size=Decimal("0.00001"),
    )

    assert metadata.base_asset == "BTC"
    assert metadata.quote_asset == "USDT"


def test_candle_reports_close_price_as_decimal():
    candle = Candle(
        symbol="BTCUSDT",
        open_time=1,
        close_time=2,
        open=Decimal("10"),
        high=Decimal("12"),
        low=Decimal("9"),
        close=Decimal("11"),
        volume=Decimal("1.5"),
    )

    assert candle.close == Decimal("11")
```

- [ ] **Step 2: Run model tests to verify they fail**

Run:

```powershell
python -m pytest tests/test_models.py -q
```

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

- [ ] **Step 3: Implement minimal shared models**

Create `src/binance_quant/models.py`:

```python
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from enum import Enum


class TradingMode(str, Enum):
    DRY_RUN = "dry_run"
    TESTNET_LIVE = "testnet_live"


class OrderSide(str, Enum):
    BUY = "BUY"
    SELL = "SELL"


class SignalType(str, Enum):
    BUY = "BUY"
    SELL = "SELL"
    HOLD = "HOLD"


@dataclass(frozen=True)
class Candle:
    symbol: str
    open_time: int
    close_time: int
    open: Decimal
    high: Decimal
    low: Decimal
    close: Decimal
    volume: Decimal

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


@dataclass(frozen=True)
class Signal:
    symbol: str
    signal_type: SignalType
    side: OrderSide | None
    reason: str
    confidence: Decimal = Decimal("1")
    created_at_ms: int | None = None

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


@dataclass(frozen=True)
class SymbolMetadata:
    symbol: str
    base_asset: str
    quote_asset: str
    min_notional: Decimal
    min_qty: Decimal
    step_size: Decimal

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


@dataclass(frozen=True)
class Balance:
    asset: str
    free: Decimal
    locked: Decimal = Decimal("0")

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


@dataclass(frozen=True)
class AccountSnapshot:
    balances: dict[str, Balance]

    def free(self, asset: str) -> Decimal:
        balance = self.balances.get(asset.upper())
        return balance.free if balance else Decimal("0")


@dataclass(frozen=True)
class RiskDecision:
    approved: bool
    reason: str
    signal: Signal
    quote_amount: Decimal | None = None


@dataclass(frozen=True)
class OrderIntent:
    symbol: str
    side: OrderSide
    quote_amount: Decimal
    client_order_id: str

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


@dataclass(frozen=True)
class OrderResult:
    symbol: str
    side: OrderSide
    client_order_id: str
    status: str
    raw: dict
    dry_run: bool

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

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

Run:

```powershell
python -m pytest tests/test_models.py -q
```

Expected: PASS.

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

Create `tests/test_config.py`:

```python
from decimal import Decimal

import pytest

from binance_quant.config import Settings, load_settings
from binance_quant.models import TradingMode


def test_load_settings_from_environment(monkeypatch):
    monkeypatch.setenv("BINANCE_MODE", "dry_run")
    monkeypatch.setenv("BINANCE_SYMBOL", "ethusdt")
    monkeypatch.setenv("BINANCE_QUOTE_AMOUNT", "12.5")
    monkeypatch.setenv("BINANCE_MAX_QUOTE_AMOUNT", "20")
    monkeypatch.setenv("BINANCE_FAST_WINDOW", "3")
    monkeypatch.setenv("BINANCE_SLOW_WINDOW", "9")
    monkeypatch.setenv("BINANCE_COOLDOWN_SECONDS", "30")
    monkeypatch.setenv("BINANCE_DATABASE_PATH", "test.sqlite3")

    settings = load_settings()

    assert settings.mode is TradingMode.DRY_RUN
    assert settings.symbol == "ETHUSDT"
    assert settings.quote_amount == Decimal("12.5")
    assert settings.max_quote_amount == Decimal("20")


def test_live_mode_requires_credentials():
    with pytest.raises(ValueError, match="Testnet live mode requires"):
        Settings(mode=TradingMode.TESTNET_LIVE, api_key="", api_secret="")


def test_fast_window_must_be_less_than_slow_window():
    with pytest.raises(ValueError, match="fast_window must be less than slow_window"):
        Settings(fast_window=20, slow_window=5)
```

- [ ] **Step 6: Run settings tests to verify they fail**

Run:

```powershell
python -m pytest tests/test_config.py -q
```

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

- [ ] **Step 7: Implement settings**

Create `src/binance_quant/config.py`:

```python
from __future__ import annotations

from decimal import Decimal
from pathlib import Path
from typing import Any

import yaml
from pydantic import Field, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

from .models import TradingMode


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="BINANCE_", env_file=".env", extra="ignore")

    api_key: str = ""
    api_secret: SecretStr = SecretStr("")
    mode: TradingMode = TradingMode.DRY_RUN
    symbol: str = "BTCUSDT"
    quote_amount: Decimal = Decimal("25")
    max_quote_amount: Decimal = Decimal("50")
    fast_window: int = 5
    slow_window: int = 20
    cooldown_seconds: int = 60
    database_path: Path = Path("binance_quant.sqlite3")
    log_level: str = "INFO"
    rest_base_url: str = "https://testnet.binance.vision/api"
    stream_base_url: str = "wss://stream.testnet.binance.vision/ws"
    prevent_repeated_side: bool = True
    recv_window: int = Field(default=5000, ge=1, le=60000)

    @field_validator("symbol")
    @classmethod
    def normalize_symbol(cls, value: str) -> str:
        value = value.strip().upper()
        if not value:
            raise ValueError("symbol must not be empty")
        return value

    @field_validator("quote_amount", "max_quote_amount")
    @classmethod
    def positive_decimal(cls, value: Decimal) -> Decimal:
        if value <= 0:
            raise ValueError("amounts must be positive")
        return value

    @model_validator(mode="after")
    def validate_windows_and_credentials(self) -> "Settings":
        if self.fast_window >= self.slow_window:
            raise ValueError("fast_window must be less than slow_window")
        if self.quote_amount > self.max_quote_amount:
            raise ValueError("quote_amount must be less than or equal to max_quote_amount")
        if self.mode is TradingMode.TESTNET_LIVE:
            if not self.api_key or not self.api_secret.get_secret_value():
                raise ValueError("Testnet live mode requires BINANCE_API_KEY and BINANCE_API_SECRET")
        return self


def load_settings(config_path: str | Path | None = None) -> Settings:
    values: dict[str, Any] = {}
    if config_path:
        path = Path(config_path)
        if path.exists():
            loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
            if not isinstance(loaded, dict):
                raise ValueError("YAML config must contain a mapping")
            values.update(loaded)
    return Settings(**values)
```

- [ ] **Step 8: Run settings tests to verify they pass**

Run:

```powershell
python -m pytest tests/test_config.py tests/test_models.py -q
```

Expected: PASS.

- [ ] **Step 9: Commit**

Run:

```powershell
git add src/binance_quant/models.py src/binance_quant/config.py tests/test_models.py tests/test_config.py
git commit -m "feat: add settings and domain models"
```

Expected: commit created.

## Task 3: SMA Crossover Strategy

**Files:**
- Create: `src/binance_quant/strategy.py`
- Test: `tests/test_strategy.py`

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

Create `tests/test_strategy.py`:

```python
from decimal import Decimal

from binance_quant.models import Candle, OrderSide, SignalType
from binance_quant.strategy import SmaCrossoverStrategy


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


def test_hold_until_enough_candles():
    strategy = SmaCrossoverStrategy(fast_window=3, slow_window=5)

    signal = strategy.on_candle(candle("100", 1))

    assert signal.signal_type is SignalType.HOLD
    assert signal.side is None


def test_buy_when_fast_sma_crosses_above_slow_sma():
    strategy = SmaCrossoverStrategy(fast_window=2, slow_window=3)
    for index, close in enumerate(["5", "4", "3"], start=1):
        strategy.on_candle(candle(close, index))

    signal = strategy.on_candle(candle("8", 4))

    assert signal.signal_type is SignalType.BUY
    assert signal.side is OrderSide.BUY
    assert "crossed above" in signal.reason


def test_sell_when_fast_sma_crosses_below_slow_sma():
    strategy = SmaCrossoverStrategy(fast_window=2, slow_window=3)
    for index, close in enumerate(["3", "4", "5"], start=1):
        strategy.on_candle(candle(close, index))

    signal = strategy.on_candle(candle("1", 4))

    assert signal.signal_type is SignalType.SELL
    assert signal.side is OrderSide.SELL
    assert "crossed below" in signal.reason
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

```powershell
python -m pytest tests/test_strategy.py -q
```

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

- [ ] **Step 3: Implement SMA crossover strategy**

Create `src/binance_quant/strategy.py`:

```python
from __future__ import annotations

from collections import deque
from decimal import Decimal

from .models import Candle, OrderSide, Signal, SignalType


class SmaCrossoverStrategy:
    def __init__(self, fast_window: int, slow_window: int) -> None:
        if fast_window <= 0 or slow_window <= 0:
            raise ValueError("SMA windows must be positive")
        if fast_window >= slow_window:
            raise ValueError("fast_window must be less than slow_window")
        self.fast_window = fast_window
        self.slow_window = slow_window
        self._closes: deque[Decimal] = deque(maxlen=slow_window)
        self._previous_relation: int | None = None

    def on_candle(self, candle: Candle) -> Signal:
        self._closes.append(candle.close)
        if len(self._closes) < self.slow_window:
            return Signal(candle.symbol, SignalType.HOLD, None, "not enough candle history", created_at_ms=candle.close_time)

        closes = list(self._closes)
        fast = sum(closes[-self.fast_window :]) / Decimal(self.fast_window)
        slow = sum(closes[-self.slow_window :]) / Decimal(self.slow_window)
        relation = 1 if fast > slow else -1 if fast < slow else 0

        previous = self._previous_relation
        self._previous_relation = relation

        if previous is not None and previous <= 0 and relation > 0:
            return Signal(
                candle.symbol,
                SignalType.BUY,
                OrderSide.BUY,
                f"fast SMA crossed above slow SMA: {fast} > {slow}",
                created_at_ms=candle.close_time,
            )
        if previous is not None and previous >= 0 and relation < 0:
            return Signal(
                candle.symbol,
                SignalType.SELL,
                OrderSide.SELL,
                f"fast SMA crossed below slow SMA: {fast} < {slow}",
                created_at_ms=candle.close_time,
            )
        return Signal(candle.symbol, SignalType.HOLD, None, "no crossover", created_at_ms=candle.close_time)
```

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

Run:

```powershell
python -m pytest tests/test_strategy.py tests/test_models.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

Run:

```powershell
git add src/binance_quant/strategy.py tests/test_strategy.py
git commit -m "feat: add sma crossover strategy"
```

Expected: commit created.

## Task 4: Risk Engine

**Files:**
- Create: `src/binance_quant/risk.py`
- Test: `tests/test_risk.py`

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

Create `tests/test_risk.py`:

```python
from decimal import Decimal

from binance_quant.models import (
    AccountSnapshot,
    Balance,
    OrderSide,
    Signal,
    SignalType,
    SymbolMetadata,
    TradingMode,
)
from binance_quant.risk import RiskEngine, RiskSettings


METADATA = SymbolMetadata(
    symbol="BTCUSDT",
    base_asset="BTC",
    quote_asset="USDT",
    min_notional=Decimal("5"),
    min_qty=Decimal("0.00001"),
    step_size=Decimal("0.00001"),
)


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


def buy_signal() -> Signal:
    return Signal("BTCUSDT", SignalType.BUY, OrderSide.BUY, "test buy")


def sell_signal() -> Signal:
    return Signal("BTCUSDT", SignalType.SELL, OrderSide.SELL, "test sell")


def test_rejects_hold_signal():
    engine = RiskEngine(RiskSettings())

    decision = engine.evaluate(
        Signal("BTCUSDT", SignalType.HOLD, None, "no crossover"),
        METADATA,
        account(),
        quote_amount=Decimal("10"),
        now_ms=1,
    )

    assert not decision.approved
    assert decision.reason == "hold signal"


def test_approves_valid_buy():
    engine = RiskEngine(RiskSettings())

    decision = engine.evaluate(buy_signal(), METADATA, account(), Decimal("10"), now_ms=1)

    assert decision.approved
    assert decision.quote_amount == Decimal("10")


def test_rejects_order_below_min_notional():
    engine = RiskEngine(RiskSettings())

    decision = engine.evaluate(buy_signal(), METADATA, account(), Decimal("1"), now_ms=1)

    assert not decision.approved
    assert decision.reason == "quote amount below min notional"


def test_rejects_buy_when_quote_balance_is_low():
    engine = RiskEngine(RiskSettings())

    decision = engine.evaluate(buy_signal(), METADATA, account(usdt="4"), Decimal("10"), now_ms=1)

    assert not decision.approved
    assert decision.reason == "insufficient quote balance"


def test_rejects_repeated_same_side_signal():
    engine = RiskEngine(RiskSettings(prevent_repeated_side=True))
    first = engine.evaluate(buy_signal(), METADATA, account(), Decimal("10"), now_ms=1)
    second = engine.evaluate(buy_signal(), METADATA, account(), Decimal("10"), now_ms=2)

    assert first.approved
    assert not second.approved
    assert second.reason == "duplicate same-side signal"


def test_rejects_during_cooldown():
    engine = RiskEngine(RiskSettings(cooldown_seconds=60, prevent_repeated_side=False))
    first = engine.evaluate(buy_signal(), METADATA, account(), Decimal("10"), now_ms=1_000)
    second = engine.evaluate(sell_signal(), METADATA, account(), Decimal("10"), now_ms=30_000)

    assert first.approved
    assert not second.approved
    assert second.reason == "cooldown active"


def test_rejects_unknown_mode():
    engine = RiskEngine(RiskSettings(mode="paper"))

    decision = engine.evaluate(buy_signal(), METADATA, account(), Decimal("10"), now_ms=1)

    assert not decision.approved
    assert decision.reason == "unsupported trading mode"


def test_accepts_explicit_testnet_live_mode():
    engine = RiskEngine(RiskSettings(mode=TradingMode.TESTNET_LIVE))

    decision = engine.evaluate(buy_signal(), METADATA, account(), Decimal("10"), now_ms=1)

    assert decision.approved


def test_rejects_unsupported_symbol():
    engine = RiskEngine(RiskSettings())
    signal = Signal("ETHUSDT", SignalType.BUY, OrderSide.BUY, "test buy")

    decision = engine.evaluate(signal, METADATA, account(), Decimal("10"), now_ms=1)

    assert not decision.approved
    assert decision.reason == "unsupported symbol"


def test_rejects_quote_amount_above_max():
    engine = RiskEngine(RiskSettings(max_quote_amount=Decimal("10")))

    decision = engine.evaluate(buy_signal(), METADATA, account(), Decimal("11"), now_ms=1)

    assert not decision.approved
    assert decision.reason == "quote amount above max"


def test_rejects_sell_when_base_balance_cannot_cover_quote_amount():
    engine = RiskEngine(RiskSettings())

    decision = engine.evaluate(
        sell_signal(),
        METADATA,
        account(btc="0.00001"),
        Decimal("10"),
        now_ms=1,
        last_price=Decimal("100"),
    )

    assert not decision.approved
    assert decision.reason == "insufficient base balance"


def test_rejected_signal_does_not_update_duplicate_state():
    engine = RiskEngine(RiskSettings(prevent_repeated_side=True))
    rejected = engine.evaluate(buy_signal(), METADATA, account(usdt="1"), Decimal("10"), now_ms=1)
    approved = engine.evaluate(buy_signal(), METADATA, account(), Decimal("10"), now_ms=2)

    assert not rejected.approved
    assert approved.approved


def test_rejected_signal_does_not_start_cooldown():
    engine = RiskEngine(RiskSettings(cooldown_seconds=60, prevent_repeated_side=False))
    rejected = engine.evaluate(buy_signal(), METADATA, account(usdt="1"), Decimal("10"), now_ms=1)
    approved = engine.evaluate(sell_signal(), METADATA, account(), Decimal("10"), now_ms=2, last_price=Decimal("100000"))

    assert not rejected.approved
    assert approved.approved


def test_allows_exact_min_and_max_quote_boundaries():
    engine = RiskEngine(RiskSettings(max_quote_amount=Decimal("5")))

    decision = engine.evaluate(buy_signal(), METADATA, account(), Decimal("5"), now_ms=1)

    assert decision.approved
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

```powershell
python -m pytest tests/test_risk.py -q
```

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

- [ ] **Step 3: Implement risk engine**

Create `src/binance_quant/risk.py`:

```python
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from .models import AccountSnapshot, OrderSide, RiskDecision, Signal, SignalType, SymbolMetadata, TradingMode


@dataclass(frozen=True)
class RiskSettings:
    mode: TradingMode | str = TradingMode.DRY_RUN
    max_quote_amount: Decimal = Decimal("50")
    cooldown_seconds: int = 60
    prevent_repeated_side: bool = True


class RiskEngine:
    def __init__(self, settings: RiskSettings) -> None:
        self.settings = settings
        self._last_trade_ms: int | None = None
        self._last_side: OrderSide | None = None

    def evaluate(
        self,
        signal: Signal,
        metadata: SymbolMetadata,
        account: AccountSnapshot,
        quote_amount: Decimal,
        now_ms: int,
        last_price: Decimal | None = None,
    ) -> RiskDecision:
        if self.settings.mode not in {TradingMode.DRY_RUN, TradingMode.TESTNET_LIVE}:
            return RiskDecision(False, "unsupported trading mode", signal)
        if signal.signal_type is SignalType.HOLD or signal.side is None:
            return RiskDecision(False, "hold signal", signal)
        if signal.symbol != metadata.symbol:
            return RiskDecision(False, "unsupported symbol", signal)
        if quote_amount > self.settings.max_quote_amount:
            return RiskDecision(False, "quote amount above max", signal)
        if quote_amount < metadata.min_notional:
            return RiskDecision(False, "quote amount below min notional", signal)
        if self._last_trade_ms is not None:
            elapsed_ms = now_ms - self._last_trade_ms
            if elapsed_ms < self.settings.cooldown_seconds * 1000:
                return RiskDecision(False, "cooldown active", signal)
        if self.settings.prevent_repeated_side and self._last_side is signal.side:
            return RiskDecision(False, "duplicate same-side signal", signal)
        if signal.side is OrderSide.BUY and account.free(metadata.quote_asset) < quote_amount:
            return RiskDecision(False, "insufficient quote balance", signal)
        if signal.side is OrderSide.SELL:
            base_value = account.free(metadata.base_asset) * last_price if last_price is not None else Decimal("0")
            if base_value < quote_amount:
                return RiskDecision(False, "insufficient base balance", signal)

        self._last_trade_ms = now_ms
        self._last_side = signal.side
        return RiskDecision(True, "approved", signal, quote_amount)
```

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

Run:

```powershell
python -m pytest tests/test_risk.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

Run:

```powershell
git add src/binance_quant/risk.py tests/test_risk.py
git commit -m "feat: add spot risk engine"
```

Expected: commit created.

## Task 5: SQLite Storage

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

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

Create `tests/test_storage.py`:

```python
from decimal import Decimal

from binance_quant.models import OrderResult, OrderSide, Signal, SignalType
from binance_quant.storage import Storage


def test_storage_records_signal_and_order(tmp_path):
    db_path = tmp_path / "audit.sqlite3"
    storage = Storage(db_path)
    storage.initialize()

    signal_id = storage.record_signal(Signal("BTCUSDT", SignalType.BUY, OrderSide.BUY, "test buy"))
    order_id = storage.record_order_result(
        OrderResult(
            symbol="BTCUSDT",
            side=OrderSide.BUY,
            client_order_id="bq-test",
            status="FILLED",
            raw={"orderId": 123},
            dry_run=True,
        )
    )

    assert signal_id == 1
    assert order_id == 1
    assert storage.list_orders()[0]["client_order_id"] == "bq-test"


def test_storage_records_risk_decision(tmp_path):
    storage = Storage(tmp_path / "audit.sqlite3")
    storage.initialize()
    signal = Signal("BTCUSDT", SignalType.BUY, OrderSide.BUY, "test buy")

    row_id = storage.record_risk_decision(signal, approved=False, reason="cooldown active", quote_amount=Decimal("10"))

    assert row_id == 1
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

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

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

- [ ] **Step 3: Implement SQLite storage**

Create `src/binance_quant/storage.py`:

```python
from __future__ import annotations

import json
import sqlite3
import time
from decimal import Decimal
from pathlib import Path
from typing import Any

from .models import OrderResult, Signal


class Storage:
    def __init__(self, path: str | Path) -> None:
        self.path = Path(path)

    def _connect(self) -> sqlite3.Connection:
        connection = sqlite3.connect(self.path)
        connection.row_factory = sqlite3.Row
        return connection

    def initialize(self) -> None:
        with self._connect() as connection:
            connection.executescript(
                """
                CREATE TABLE IF NOT EXISTS signals (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    created_at_ms INTEGER NOT NULL,
                    symbol TEXT NOT NULL,
                    signal_type TEXT NOT NULL,
                    side TEXT,
                    reason TEXT NOT NULL,
                    confidence TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS risk_decisions (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    created_at_ms INTEGER NOT NULL,
                    symbol TEXT NOT NULL,
                    signal_type TEXT NOT NULL,
                    side TEXT,
                    approved INTEGER NOT NULL,
                    reason TEXT NOT NULL,
                    quote_amount TEXT
                );
                CREATE TABLE IF NOT EXISTS order_results (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    created_at_ms INTEGER NOT NULL,
                    symbol TEXT NOT NULL,
                    side TEXT NOT NULL,
                    client_order_id TEXT NOT NULL,
                    status TEXT NOT NULL,
                    dry_run INTEGER NOT NULL,
                    raw_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS runtime_events (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    created_at_ms INTEGER NOT NULL,
                    level TEXT NOT NULL,
                    message TEXT NOT NULL
                );
                """
            )

    def record_signal(self, signal: Signal) -> int:
        created_at_ms = signal.created_at_ms or _now_ms()
        with self._connect() as connection:
            cursor = connection.execute(
                """
                INSERT INTO signals (created_at_ms, symbol, signal_type, side, reason, confidence)
                VALUES (?, ?, ?, ?, ?, ?)
                """,
                (
                    created_at_ms,
                    signal.symbol,
                    signal.signal_type.value,
                    signal.side.value if signal.side else None,
                    signal.reason,
                    str(signal.confidence),
                ),
            )
            return int(cursor.lastrowid)

    def record_risk_decision(self, signal: Signal, approved: bool, reason: str, quote_amount: Decimal | None) -> int:
        with self._connect() as connection:
            cursor = connection.execute(
                """
                INSERT INTO risk_decisions (created_at_ms, symbol, signal_type, side, approved, reason, quote_amount)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    signal.created_at_ms or _now_ms(),
                    signal.symbol,
                    signal.signal_type.value,
                    signal.side.value if signal.side else None,
                    1 if approved else 0,
                    reason,
                    str(quote_amount) if quote_amount is not None else None,
                ),
            )
            return int(cursor.lastrowid)

    def record_order_result(self, result: OrderResult) -> int:
        with self._connect() as connection:
            cursor = connection.execute(
                """
                INSERT INTO order_results (created_at_ms, symbol, side, client_order_id, status, dry_run, raw_json)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    _now_ms(),
                    result.symbol,
                    result.side.value,
                    result.client_order_id,
                    result.status,
                    1 if result.dry_run else 0,
                    json.dumps(result.raw, sort_keys=True),
                ),
            )
            return int(cursor.lastrowid)

    def list_orders(self, limit: int = 50) -> list[dict[str, Any]]:
        with self._connect() as connection:
            rows = connection.execute(
                "SELECT * FROM order_results ORDER BY id DESC LIMIT ?",
                (limit,),
            ).fetchall()
            return [dict(row) for row in rows]


def _now_ms() -> int:
    return int(time.time() * 1000)
```

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

Run:

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

Expected: PASS.

- [ ] **Step 5: Commit**

Run:

```powershell
git add src/binance_quant/storage.py tests/test_storage.py
git commit -m "feat: add sqlite audit storage"
```

Expected: commit created.

## Task 6: Binance Spot Testnet REST Client

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

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

Create `tests/test_exchange.py`:

```python
from decimal import Decimal

import httpx
import pytest

from binance_quant.exchange import BinanceSpotClient, sign_query
from binance_quant.models import AccountSnapshot, OrderSide


def test_sign_query_uses_hmac_sha256():
    signature = sign_query("symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000&timestamp=1499827319559", "NhqPtmdSJY")

    assert signature == "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71"


def test_client_uses_testnet_base_url():
    client = BinanceSpotClient(api_key="key", api_secret="secret")

    assert client.base_url == "https://testnet.binance.vision/api"


def test_parse_account_snapshot():
    snapshot = BinanceSpotClient.parse_account(
        {
            "balances": [
                {"asset": "BTC", "free": "0.1", "locked": "0.0"},
                {"asset": "USDT", "free": "100", "locked": "0.0"},
            ]
        }
    )

    assert isinstance(snapshot, AccountSnapshot)
    assert snapshot.free("USDT") == Decimal("100")


def test_create_market_buy_order_sends_quote_order_qty():
    requests = []

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

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

    result = client.create_market_order("BTCUSDT", OrderSide.BUY, Decimal("25"), "abc", timestamp_ms=1)

    body = requests[0].content.decode()
    assert result["status"] == "FILLED"
    assert "quoteOrderQty=25" in body
    assert "newClientOrderId=abc" in body
    assert requests[0].headers["X-MBX-APIKEY"] == "key"
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

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

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

- [ ] **Step 3: Implement REST client**

Create `src/binance_quant/exchange.py`:

```python
from __future__ import annotations

import hashlib
import hmac
import time
from decimal import Decimal
from urllib.parse import urlencode

import httpx

from .models import AccountSnapshot, Balance, OrderSide, SymbolMetadata


TESTNET_REST_BASE_URL = "https://testnet.binance.vision/api"


def sign_query(query_string: str, api_secret: str) -> str:
    return hmac.new(api_secret.encode("utf-8"), query_string.encode("utf-8"), hashlib.sha256).hexdigest()


class BinanceSpotClient:
    def __init__(
        self,
        api_key: str,
        api_secret: str,
        base_url: str = TESTNET_REST_BASE_URL,
        http_client: httpx.Client | None = None,
        recv_window: int = 5000,
    ) -> None:
        self.api_key = api_key
        self.api_secret = api_secret
        self.base_url = base_url.rstrip("/")
        self.recv_window = recv_window
        self._client = http_client or httpx.Client(timeout=10)

    def account(self, timestamp_ms: int | None = None) -> AccountSnapshot:
        payload = self._signed_request("GET", "/v3/account", {}, timestamp_ms)
        return self.parse_account(payload)

    def exchange_info(self, symbol: str) -> SymbolMetadata:
        response = self._client.get(f"{self.base_url}/v3/exchangeInfo", params={"symbol": symbol.upper()})
        response.raise_for_status()
        payload = response.json()
        symbol_info = payload["symbols"][0]
        filters = {item["filterType"]: item for item in symbol_info["filters"]}
        lot_size = filters.get("LOT_SIZE", {})
        min_notional_filter = filters.get("MIN_NOTIONAL") or filters.get("NOTIONAL") or {}
        min_notional = Decimal(min_notional_filter.get("minNotional", "0"))
        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")),
        )

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

    def _signed_request(
        self,
        method: str,
        path: str,
        params: dict[str, str],
        timestamp_ms: int | None = None,
    ) -> dict:
        signed_params = dict(params)
        signed_params["recvWindow"] = str(self.recv_window)
        signed_params["timestamp"] = str(timestamp_ms if timestamp_ms is not None else int(time.time() * 1000))
        query = urlencode(signed_params)
        signature = sign_query(query, self.api_secret)
        signed_params["signature"] = signature
        headers = {"X-MBX-APIKEY": self.api_key}
        response = self._client.request(method, f"{self.base_url}{path}", data=signed_params, headers=headers)
        response.raise_for_status()
        return response.json()

    @staticmethod
    def parse_account(payload: dict) -> AccountSnapshot:
        balances = {
            row["asset"].upper(): Balance(
                asset=row["asset"],
                free=Decimal(row["free"]),
                locked=Decimal(row.get("locked", "0")),
            )
            for row in payload.get("balances", [])
        }
        return AccountSnapshot(balances)
```

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

Run:

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

Expected: PASS.

- [ ] **Step 5: Commit**

Run:

```powershell
git add src/binance_quant/exchange.py tests/test_exchange.py
git commit -m "feat: add binance spot testnet client"
```

Expected: commit created.

## Task 7: Execution Engine

**Files:**
- Create: `src/binance_quant/execution.py`
- Test: `tests/test_execution.py`

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

Create `tests/test_execution.py`:

```python
from decimal import Decimal

from binance_quant.execution import ExecutionEngine
from binance_quant.models import OrderIntent, OrderSide, TradingMode


class FakeExchange:
    def __init__(self):
        self.orders = []

    def create_market_order(self, symbol, side, quote_amount, client_order_id):
        self.orders.append((symbol, side, quote_amount, client_order_id))
        return {"symbol": symbol, "status": "FILLED", "clientOrderId": client_order_id}


def test_dry_run_execution_does_not_call_exchange():
    exchange = FakeExchange()
    engine = ExecutionEngine(mode=TradingMode.DRY_RUN, exchange_client=exchange)
    intent = OrderIntent("BTCUSDT", OrderSide.BUY, Decimal("25"), "bq-1")

    result = engine.execute(intent)

    assert result.dry_run is True
    assert result.status == "DRY_RUN"
    assert exchange.orders == []


def test_testnet_live_execution_calls_exchange():
    exchange = FakeExchange()
    engine = ExecutionEngine(mode=TradingMode.TESTNET_LIVE, exchange_client=exchange)
    intent = OrderIntent("BTCUSDT", OrderSide.BUY, Decimal("25"), "bq-1")

    result = engine.execute(intent)

    assert result.dry_run is False
    assert result.status == "FILLED"
    assert exchange.orders == [("BTCUSDT", OrderSide.BUY, Decimal("25"), "bq-1")]
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

```powershell
python -m pytest tests/test_execution.py -q
```

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

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

Create `src/binance_quant/execution.py`:

```python
from __future__ import annotations

import uuid
from decimal import Decimal

from .models import OrderIntent, OrderResult, OrderSide, RiskDecision, TradingMode


def build_client_order_id(prefix: str = "bq") -> str:
    return f"{prefix}-{uuid.uuid4().hex[:24]}"


def order_intent_from_decision(decision: RiskDecision) -> OrderIntent:
    if not decision.approved or decision.signal.side is None or decision.quote_amount is None:
        raise ValueError("approved risk decision with side and quote_amount is required")
    return OrderIntent(
        symbol=decision.signal.symbol,
        side=decision.signal.side,
        quote_amount=decision.quote_amount,
        client_order_id=build_client_order_id(),
    )


class ExecutionEngine:
    def __init__(self, mode: TradingMode, exchange_client) -> None:
        self.mode = mode
        self.exchange_client = exchange_client

    def execute(self, intent: OrderIntent) -> OrderResult:
        if self.mode is TradingMode.DRY_RUN:
            return OrderResult(
                symbol=intent.symbol,
                side=intent.side,
                client_order_id=intent.client_order_id,
                status="DRY_RUN",
                raw={
                    "symbol": intent.symbol,
                    "side": intent.side.value,
                    "quoteOrderQty": str(intent.quote_amount),
                    "clientOrderId": intent.client_order_id,
                },
                dry_run=True,
            )
        if self.mode is TradingMode.TESTNET_LIVE:
            payload = self.exchange_client.create_market_order(
                intent.symbol,
                intent.side,
                intent.quote_amount,
                intent.client_order_id,
            )
            return OrderResult(
                symbol=intent.symbol,
                side=intent.side,
                client_order_id=intent.client_order_id,
                status=payload.get("status", "UNKNOWN"),
                raw=payload,
                dry_run=False,
            )
        raise ValueError(f"unsupported trading mode: {self.mode}")
```

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

Run:

```powershell
python -m pytest tests/test_execution.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

Run:

```powershell
git add src/binance_quant/execution.py tests/test_execution.py
git commit -m "feat: add order execution engine"
```

Expected: commit created.

## Task 8: Market Data Loading and Streaming

**Files:**
- Create: `src/binance_quant/market_data.py`
- Test: `tests/test_market_data.py`

- [ ] **Step 1: Write failing market data tests**

Create `tests/test_market_data.py`:

```python
from decimal import Decimal

from binance_quant.market_data import kline_payload_to_candle, load_candles_csv


def test_kline_payload_to_closed_candle():
    payload = {
        "s": "BTCUSDT",
        "k": {
            "t": 1,
            "T": 2,
            "o": "10",
            "h": "12",
            "l": "9",
            "c": "11",
            "v": "1.5",
            "x": True,
        },
    }

    candle = kline_payload_to_candle(payload)

    assert candle.symbol == "BTCUSDT"
    assert candle.close == Decimal("11")


def test_kline_payload_returns_none_for_open_candle():
    payload = {"s": "BTCUSDT", "k": {"x": False}}

    assert kline_payload_to_candle(payload) is None


def test_load_candles_csv(tmp_path):
    csv_path = tmp_path / "candles.csv"
    csv_path.write_text(
        "open_time,open,high,low,close,volume,close_time\n"
        "1,10,12,9,11,1.5,2\n",
        encoding="utf-8",
    )

    candles = list(load_candles_csv(csv_path, "BTCUSDT"))

    assert candles[0].close == Decimal("11")
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

```powershell
python -m pytest tests/test_market_data.py -q
```

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

- [ ] **Step 3: Implement market data helpers**

Create `src/binance_quant/market_data.py`:

```python
from __future__ import annotations

import asyncio
import csv
import json
from collections.abc import AsyncIterator, Iterator
from decimal import Decimal
from pathlib import Path

import websockets

from .models import Candle


TESTNET_STREAM_BASE_URL = "wss://stream.testnet.binance.vision/ws"


def kline_payload_to_candle(payload: dict) -> Candle | None:
    kline = payload.get("k", {})
    if not kline.get("x"):
        return None
    symbol = payload.get("s") or kline.get("s")
    return Candle(
        symbol=symbol,
        open_time=int(kline["t"]),
        close_time=int(kline["T"]),
        open=Decimal(kline["o"]),
        high=Decimal(kline["h"]),
        low=Decimal(kline["l"]),
        close=Decimal(kline["c"]),
        volume=Decimal(kline["v"]),
    )


def load_candles_csv(path: str | Path, symbol: str) -> Iterator[Candle]:
    with Path(path).open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle)
        for row in reader:
            yield Candle(
                symbol=symbol,
                open_time=int(row["open_time"]),
                close_time=int(row["close_time"]),
                open=Decimal(row["open"]),
                high=Decimal(row["high"]),
                low=Decimal(row["low"]),
                close=Decimal(row["close"]),
                volume=Decimal(row["volume"]),
            )


async def stream_closed_klines(
    symbol: str,
    interval: str = "1m",
    stream_base_url: str = TESTNET_STREAM_BASE_URL,
) -> AsyncIterator[Candle]:
    stream_name = f"{symbol.lower()}@kline_{interval}"
    url = f"{stream_base_url.rstrip('/')}/{stream_name}"
    async with websockets.connect(url) as websocket:
        async for raw_message in websocket:
            payload = json.loads(raw_message)
            candle = kline_payload_to_candle(payload)
            if candle is not None:
                yield candle
            await asyncio.sleep(0)
```

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

Run:

```powershell
python -m pytest tests/test_market_data.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

Run:

```powershell
git add src/binance_quant/market_data.py tests/test_market_data.py
git commit -m "feat: add spot market data helpers"
```

Expected: commit created.

## Task 9: Backtest Runner

**Files:**
- Create: `src/binance_quant/backtest.py`
- Test: `tests/test_backtest.py`

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

Create `tests/test_backtest.py`:

```python
from decimal import Decimal

from binance_quant.backtest import BacktestResult, run_backtest
from binance_quant.models import Candle


def candle(close: str, index: int) -> Candle:
    value = Decimal(close)
    return Candle("BTCUSDT", index, index + 1, value, value, value, value, Decimal("1"))


def test_run_backtest_counts_signals():
    candles = [candle(value, index) for index, value in enumerate(["5", "4", "3", "8", "9", "2"], start=1)]

    result = run_backtest(candles, fast_window=2, slow_window=3)

    assert isinstance(result, BacktestResult)
    assert result.total_candles == 6
    assert result.buy_signals >= 1
    assert result.sell_signals >= 1
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

```powershell
python -m pytest tests/test_backtest.py -q
```

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

- [ ] **Step 3: Implement backtest runner**

Create `src/binance_quant/backtest.py`:

```python
from __future__ import annotations

from dataclasses import dataclass
from collections.abc import Iterable

from .models import Candle, SignalType
from .strategy import SmaCrossoverStrategy


@dataclass(frozen=True)
class BacktestResult:
    total_candles: int
    buy_signals: int
    sell_signals: int
    hold_signals: int


def run_backtest(candles: Iterable[Candle], fast_window: int, slow_window: int) -> BacktestResult:
    strategy = SmaCrossoverStrategy(fast_window=fast_window, slow_window=slow_window)
    total = 0
    buys = 0
    sells = 0
    holds = 0
    for candle in candles:
        total += 1
        signal = strategy.on_candle(candle)
        if signal.signal_type is SignalType.BUY:
            buys += 1
        elif signal.signal_type is SignalType.SELL:
            sells += 1
        else:
            holds += 1
    return BacktestResult(total, buys, sells, holds)
```

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

Run:

```powershell
python -m pytest tests/test_backtest.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

Run:

```powershell
git add src/binance_quant/backtest.py tests/test_backtest.py
git commit -m "feat: add sma backtest runner"
```

Expected: commit created.

## Task 10: Trade Loop Runner

**Files:**
- Create: `src/binance_quant/runner.py`
- Test: `tests/test_runner.py`

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

Create `tests/test_runner.py`:

```python
import asyncio
from decimal import Decimal

from binance_quant.config import Settings
from binance_quant.models import AccountSnapshot, Balance, Candle, OrderSide, SymbolMetadata, TradingMode
from binance_quant.runner import run_trade_loop
from binance_quant.storage import Storage


class FakeExchange:
    def __init__(self) -> None:
        self.orders = []

    def exchange_info(self, symbol: str) -> SymbolMetadata:
        return SymbolMetadata(
            symbol=symbol,
            base_asset="BTC",
            quote_asset="USDT",
            min_notional=Decimal("5"),
            min_qty=Decimal("0.00001"),
            step_size=Decimal("0.00001"),
        )

    def account(self) -> AccountSnapshot:
        return AccountSnapshot(
            {
                "USDT": Balance("USDT", Decimal("100")),
                "BTC": Balance("BTC", Decimal("1")),
            }
        )

    def create_market_order(self, symbol, side, quote_amount, client_order_id):
        self.orders.append((symbol, side, quote_amount, client_order_id))
        return {"symbol": symbol, "status": "FILLED", "clientOrderId": client_order_id}


def candle(close: str, index: int) -> Candle:
    value = Decimal(close)
    return Candle("BTCUSDT", index, index + 1, value, value, value, value, Decimal("1"))


async def candle_stream(candles):
    for item in candles:
        yield item


def test_dry_run_trade_loop_records_order(tmp_path):
    settings = Settings(
        mode=TradingMode.DRY_RUN,
        fast_window=2,
        slow_window=3,
        cooldown_seconds=0,
        quote_amount=Decimal("10"),
        max_quote_amount=Decimal("20"),
        database_path=tmp_path / "audit.sqlite3",
    )
    exchange = FakeExchange()
    candles = [candle(value, index) for index, value in enumerate(["5", "4", "3", "8"], start=1)]

    summary = asyncio.run(
        run_trade_loop(
            settings,
            exchange_client=exchange,
            candle_stream=candle_stream(candles),
            max_candles=4,
        )
    )

    assert summary.processed_candles == 4
    assert summary.orders == 1
    assert exchange.orders == []
    assert Storage(settings.database_path).list_orders()[0]["status"] == "DRY_RUN"


def test_testnet_live_trade_loop_calls_exchange(tmp_path):
    settings = Settings(
        mode=TradingMode.TESTNET_LIVE,
        api_key="key",
        api_secret="secret",
        fast_window=2,
        slow_window=3,
        cooldown_seconds=0,
        quote_amount=Decimal("10"),
        max_quote_amount=Decimal("20"),
        database_path=tmp_path / "audit.sqlite3",
    )
    exchange = FakeExchange()
    candles = [candle(value, index) for index, value in enumerate(["5", "4", "3", "8"], start=1)]

    summary = asyncio.run(
        run_trade_loop(
            settings,
            exchange_client=exchange,
            candle_stream=candle_stream(candles),
            max_candles=4,
        )
    )

    assert summary.orders == 1
    assert exchange.orders[0][1] is OrderSide.BUY
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

```powershell
python -m pytest tests/test_runner.py -q
```

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

- [ ] **Step 3: Implement trade loop runner**

Create `src/binance_quant/runner.py`:

```python
from __future__ import annotations

from collections.abc import AsyncIterable
from dataclasses import dataclass
from decimal import Decimal

from .config import Settings
from .exchange import BinanceSpotClient
from .execution import ExecutionEngine, order_intent_from_decision
from .market_data import stream_closed_klines
from .models import AccountSnapshot, Balance, Candle, TradingMode
from .risk import RiskEngine, RiskSettings
from .storage import Storage
from .strategy import SmaCrossoverStrategy


@dataclass(frozen=True)
class TradeLoopSummary:
    processed_candles: int
    signals: int
    approved: int
    rejected: int
    orders: int


async def run_trade_loop(
    settings: Settings,
    exchange_client=None,
    candle_stream: AsyncIterable[Candle] | None = None,
    max_candles: int | None = None,
) -> TradeLoopSummary:
    storage = Storage(settings.database_path)
    storage.initialize()

    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,
    )
    metadata = exchange.exchange_info(settings.symbol)
    candles = candle_stream or stream_closed_klines(settings.symbol, stream_base_url=settings.stream_base_url)
    strategy = SmaCrossoverStrategy(settings.fast_window, settings.slow_window)
    risk = RiskEngine(
        RiskSettings(
            mode=settings.mode,
            max_quote_amount=settings.max_quote_amount,
            cooldown_seconds=settings.cooldown_seconds,
            prevent_repeated_side=settings.prevent_repeated_side,
        )
    )
    execution = ExecutionEngine(settings.mode, exchange)

    processed = signals = approved = rejected = orders = 0
    async for candle in candles:
        processed += 1
        signal = strategy.on_candle(candle)
        storage.record_signal(signal)
        signals += 1

        account = _account_for_mode(settings.mode, exchange, metadata.base_asset, metadata.quote_asset, settings.max_quote_amount)
        decision = risk.evaluate(
            signal,
            metadata,
            account,
            settings.quote_amount,
            now_ms=candle.close_time,
            last_price=candle.close,
        )
        storage.record_risk_decision(signal, decision.approved, decision.reason, decision.quote_amount)
        if not decision.approved:
            rejected += 1
        else:
            approved += 1
            result = execution.execute(order_intent_from_decision(decision))
            storage.record_order_result(result)
            orders += 1

        if max_candles is not None and processed >= max_candles:
            break

    return TradeLoopSummary(processed, signals, approved, rejected, orders)


def _account_for_mode(
    mode: TradingMode,
    exchange,
    base_asset: str,
    quote_asset: str,
    max_quote_amount: Decimal,
) -> AccountSnapshot:
    if mode is TradingMode.TESTNET_LIVE:
        return exchange.account()
    return AccountSnapshot(
        {
            quote_asset: Balance(quote_asset, max_quote_amount * Decimal("100")),
            base_asset: Balance(base_asset, Decimal("1")),
        }
    )
```

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

Run:

```powershell
python -m pytest tests/test_runner.py -q
```

Expected: PASS.

- [ ] **Step 5: Commit**

Run:

```powershell
git add src/binance_quant/runner.py tests/test_runner.py
git commit -m "feat: add trade loop runner"
```

Expected: commit created.

## Task 11: CLI Commands and Integration Wiring

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

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

Create `tests/test_cli.py`:

```python
from binance_quant.runner import TradeLoopSummary
from typer.testing import CliRunner

from binance_quant.cli import app


runner = CliRunner()


def test_version_command():
    result = runner.invoke(app, ["version"])

    assert result.exit_code == 0
    assert "0.1.0" in result.stdout


def test_orders_command_initializes_empty_database(tmp_path):
    db_path = tmp_path / "audit.sqlite3"

    result = runner.invoke(app, ["orders", "--database-path", str(db_path)])

    assert result.exit_code == 0
    assert "No orders found" in result.stdout


def test_trade_command_runs_loop_with_config(tmp_path, monkeypatch):
    config_path = tmp_path / "config.yml"
    db_path = tmp_path / "audit.sqlite3"
    config_path.write_text(
        f"mode: dry_run\nfast_window: 2\nslow_window: 3\ndatabase_path: {db_path}\n",
        encoding="utf-8",
    )
    captured = {}

    async def fake_run_trade_loop(settings, max_candles=None):
        captured["symbol"] = settings.symbol
        captured["max_candles"] = max_candles
        return TradeLoopSummary(processed_candles=1, signals=1, approved=0, rejected=1, orders=0)

    monkeypatch.setattr("binance_quant.cli.run_trade_loop", fake_run_trade_loop)

    result = runner.invoke(app, ["trade", "--config", str(config_path), "--max-candles", "1"])

    assert result.exit_code == 0
    assert captured == {"symbol": "BTCUSDT", "max_candles": 1}
    assert "Processed candles: 1" in result.stdout
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

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

Expected: FAIL because `binance_quant.cli` exists only as a minimal app and the requested commands do not exist yet.

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

Replace `src/binance_quant/cli.py` with:

```python
from __future__ import annotations

import asyncio
from pathlib import Path

import typer
from rich.console import Console
from rich.table import Table

from . import __version__
from .backtest import run_backtest
from .config import load_settings
from .exchange import BinanceSpotClient
from .market_data import load_candles_csv
from .runner import run_trade_loop
from .storage import Storage


app = typer.Typer(no_args_is_help=True)
console = Console()


@app.command()
def version() -> None:
    console.print(__version__)


@app.command()
def backtest(
    csv_path: Path = typer.Argument(..., exists=True, readable=True),
    symbol: str = typer.Option("BTCUSDT", help="Trading symbol"),
    fast_window: int = typer.Option(5, help="Fast SMA window"),
    slow_window: int = typer.Option(20, help="Slow SMA window"),
) -> None:
    result = run_backtest(load_candles_csv(csv_path, symbol), fast_window, slow_window)
    table = Table("Metric", "Value")
    table.add_row("Candles", str(result.total_candles))
    table.add_row("Buy signals", str(result.buy_signals))
    table.add_row("Sell signals", str(result.sell_signals))
    table.add_row("Hold signals", str(result.hold_signals))
    console.print(table)


@app.command()
def orders(database_path: Path = typer.Option(Path("binance_quant.sqlite3"), help="SQLite database path")) -> None:
    storage = Storage(database_path)
    storage.initialize()
    rows = storage.list_orders()
    if not rows:
        console.print("No orders found")
        return
    table = Table("ID", "Symbol", "Side", "Status", "Client Order ID", "Dry Run")
    for row in rows:
        table.add_row(
            str(row["id"]),
            row["symbol"],
            row["side"],
            row["status"],
            row["client_order_id"],
            "yes" if row["dry_run"] else "no",
        )
    console.print(table)


@app.command()
def account(config: Path | None = typer.Option(None, help="Optional YAML config path")) -> None:
    settings = load_settings(config)
    client = 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,
    )
    snapshot = client.account()
    table = Table("Asset", "Free", "Locked")
    for asset, balance in sorted(snapshot.balances.items()):
        if balance.free or balance.locked:
            table.add_row(asset, str(balance.free), str(balance.locked))
    console.print(table)


@app.command()
def trade(
    config: Path | None = typer.Option(None, help="Optional YAML config path"),
    max_candles: int | None = typer.Option(None, help="Stop after this many closed candles"),
) -> None:
    settings = load_settings(config)
    summary = asyncio.run(run_trade_loop(settings, max_candles=max_candles))
    console.print(f"Processed candles: {summary.processed_candles}")
    console.print(f"Signals: {summary.signals}")
    console.print(f"Approved: {summary.approved}")
    console.print(f"Rejected: {summary.rejected}")
    console.print(f"Orders: {summary.orders}")
```

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

Run:

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

Expected: PASS.

- [ ] **Step 5: Update README**

Modify `README.md`:

```markdown
# Binance Quant

Python CLI trading system for Binance Spot Testnet.

The first version supports dry-run mode, Spot Testnet market orders, SQLite audit storage, and an SMA crossover strategy. Real-money trading is not enabled.

## Setup

```powershell
python -m pip install -e ".[dev]"
Copy-Item .env.example .env
```

Edit `.env` with Binance Spot Testnet credentials when using `testnet_live`.

## Commands

```powershell
binance-quant version
binance-quant backtest data/candles.csv --symbol BTCUSDT --fast-window 5 --slow-window 20
binance-quant orders --database-path binance_quant.sqlite3
binance-quant account
binance-quant trade --max-candles 10
```

`trade` runs indefinitely unless `--max-candles` is provided. In `dry_run` mode it records simulated orders. In `testnet_live` mode it requires Spot Testnet credentials and can submit market orders to Binance Spot Testnet.
```

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

Run:

```powershell
python -m pytest
```

Expected: all tests pass.

- [ ] **Step 7: Commit**

Run:

```powershell
git add src/binance_quant/cli.py tests/test_cli.py README.md
git commit -m "feat: add cli commands"
```

Expected: commit created.

## Final Verification

- [ ] **Step 1: Run full unit test suite**

Run:

```powershell
python -m pytest
```

Expected: all tests pass.

- [ ] **Step 2: Verify CLI entry point**

Run:

```powershell
binance-quant version
```

Expected: prints `0.1.0`.

- [ ] **Step 3: Verify no secrets are tracked**

Run:

```powershell
git status --short
git ls-files | Select-String -Pattern '\.env$'
```

Expected: `.env` is not tracked.

- [ ] **Step 4: Optional Testnet account check**

Only run after setting valid Binance Spot Testnet credentials:

```powershell
binance-quant account
```

Expected: account balances from Spot Testnet are displayed, or a Binance authentication error is shown without printing secrets.
