diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..810119d --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy to .env and fill in. The behavioral-analysis agent (Phase 3) needs a key. + +# Required for `POST /journal/analyze` and `python -m backtest.coach`. +ANTHROPIC_API_KEY= + +# Optional. Defaults to claude-opus-4-8. +# ANTHROPIC_MODEL=claude-opus-4-8 + +# Optional. Path to the SQLite database (metrics + journal). Defaults to metrics.db. +# METRICS_DB=metrics.db diff --git a/.gitignore b/.gitignore index b8a5dcc..99005d7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ out/ *.db *.sqlite3 +# Secrets (Claude API key etc.) +.env + # Editor / OS .vscode/ .idea/ diff --git a/README.md b/README.md index 8cea1d3..fff378b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Early scaffolding. Roadmap: - [x] Phase 0 — Scaffolding: monorepo layout, linters, CI. - [x] Phase 1 — Vertical slice: sample tick CSV → order-flow imbalance → SQLite → API endpoint. - [x] Phase 2 — More metrics (realized volatility, volume, VWAP), tests against known data. -- [ ] Phase 3 — Trade journal model + Claude API behavioral agent. +- [x] Phase 3 — Trade journal model + Claude API behavioral agent. - [ ] Phase 4 — Next.js dashboard. - [ ] Phase 5 — v0.1.0 release with sample-data demo. @@ -63,6 +63,28 @@ curl 'localhost:8000/metrics/volatility?limit=5' # realized volatility + trade curl 'localhost:8000/metrics/volume?limit=5' # buy/sell/total volume + VWAP ``` +Phase 3 adds a trade journal (stored alongside the metrics) and a Claude-powered +behavioral agent that reads it. The agent needs an Anthropic API key; it produces +behavioral observations correlated with the market regime — **not** trading advice +or price predictions. + +```bash +# 5. Generate a synthetic trade journal (deterministic; all synthetic) +python3 data/generate_journal.py # writes data/sample_journal.csv + +# 6. Analyze behavior with Claude (defaults to claude-opus-4-8; +# override with ANTHROPIC_MODEL). --load seeds the journal into the DB first. +export ANTHROPIC_API_KEY=sk-ant-... +python3 -m backtest.coach --load data/sample_journal.csv --db metrics.db + +# 7. Or drive the journal over the API (shares the same METRICS_DB) +curl -X POST localhost:8000/journal -H 'content-type: application/json' \ + -d '{"symbol":"MNQ","side":"long","entered_at_ns":1760000030000000000, + "entry_price":21400,"size":1,"notes":"followed my plan","emotion":"calm"}' +curl 'localhost:8000/journal?limit=5' # entries joined to the market regime +curl -X POST 'localhost:8000/journal/analyze' # Claude behavioral analysis (503 without a key) +``` + Run the checks the same way CI does: ```bash @@ -112,3 +134,16 @@ calls were made by me — as the project progresses. - **VWAP folded into the same pass** (Phase 2, Claude's proposal, accepted): it needs only the running `Σ price·size` the engine already touches per tick, so it comes almost for free and gives the dashboard a price anchor alongside the volume figures. +- **The trade journal is Python-owned and lives in the same database** (Phase 3, mine): + unlike `metrics`, the journal is written and read by the Python + agent layer, not the + Rust hot path, so Python owns the `journal_entries` schema — a deliberate inversion of + the Phase 1 "the engine owns the schema" rule. Keeping it in the same SQLite file lets + each entry join to the microstructure regime at its entry time (`GET /journal` and the + agent both use that join), which is the whole point of the feature. SQLite only, no ORM, + to stay consistent with the rest of the stack; Postgres in the diagram stays a future + option, not a Phase 3 dependency. +- **Behavioral agent defaults to `claude-opus-4-8`** (Phase 3, mine): overridable via the + `ANTHROPIC_MODEL` env var. It emits behavioral observations tied to the regime — by + design **not** trading advice and **not** price predictions. The Anthropic client is + dependency-injected, so the analysis is unit-tested against a mock and CI needs neither + a key nor network access. diff --git a/backtest/pyproject.toml b/backtest/pyproject.toml index 943c903..08af5de 100644 --- a/backtest/pyproject.toml +++ b/backtest/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi>=0.110", "uvicorn>=0.29", + "anthropic>=0.117", ] [project.optional-dependencies] diff --git a/backtest/src/backtest/api.py b/backtest/src/backtest/api.py index 027cc03..967a36d 100644 --- a/backtest/src/backtest/api.py +++ b/backtest/src/backtest/api.py @@ -1,22 +1,59 @@ -"""Read-only API over the metrics database produced by the Rust engine. - -The engine owns the schema; this layer only exposes it. Point it at a -database with the METRICS_DB environment variable (default: metrics.db -in the working directory). +"""API over the metrics database and the trade journal. + +The metrics endpoints are read-only: the Rust engine owns that schema and this +layer only exposes it. The journal endpoints (Phase 3) are read/write — the +journal is owned by this Python layer and stored alongside the metrics in the +same database, so entries can be joined to the market regime at entry time. Point +the app at a database with the METRICS_DB environment variable (default: +metrics.db in the working directory). """ import os import sqlite3 from pathlib import Path -from typing import Any +from typing import Annotated, Any + +from fastapi import Depends, FastAPI, HTTPException, Query +from pydantic import BaseModel -from fastapi import FastAPI, HTTPException, Query +from backtest import coach, journal DB_PATH_ENV = "METRICS_DB" app = FastAPI(title="Trading Microstructure Engine API") +class JournalEntryIn(BaseModel): + """A trade the user is logging to their journal.""" + + symbol: str + side: str + entered_at_ns: int + entry_price: float + size: int + exited_at_ns: int | None = None + exit_price: float | None = None + pnl: float | None = None + notes: str | None = None + emotion: str | None = None + + +def get_anthropic_client() -> Any: + """Provide an Anthropic client, or 503 when no API key is configured. + + Overridable in tests via ``app.dependency_overrides`` so the behavioral + endpoint can be exercised without a real key or network access. + """ + if not os.environ.get("ANTHROPIC_API_KEY"): + raise HTTPException( + status_code=503, + detail="ANTHROPIC_API_KEY is not set — the behavioral agent is unavailable", + ) + import anthropic + + return anthropic.Anthropic() + + def _db_path() -> Path: return Path(os.environ.get(DB_PATH_ENV, "metrics.db")) @@ -67,3 +104,30 @@ def get_volume(limit: int = Query(default=100, ge=1, le=10_000)) -> list[dict[st "bucket_start_ns, window_ns, buy_volume, sell_volume, total_volume, vwap, trade_count", limit, ) + + +@app.post("/journal", status_code=201) +def create_journal_entry(entry: JournalEntryIn) -> dict[str, Any]: + """Log a trade to the journal, returning the stored row (with its id).""" + return journal.insert_entry(_db_path(), entry.model_dump()) + + +@app.get("/journal") +def get_journal(limit: int = Query(default=100, ge=1, le=10_000)) -> list[dict[str, Any]]: + """Return journal entries newest first, each joined to its market regime. + + An empty journal (or a database that does not exist yet) returns ``[]`` — + unlike the metrics endpoints, the journal is created by this layer, so + "no entries yet" is a normal state rather than a 503. + """ + return journal.list_enriched(_db_path(), limit) + + +@app.post("/journal/analyze") +def analyze_journal( + client: Annotated[Any, Depends(get_anthropic_client)], + limit: int = Query(default=100, ge=1, le=10_000), +) -> coach.BehavioralAnalysis: + """Run the Claude behavioral-analysis agent over the journal.""" + entries = journal.list_enriched(_db_path(), limit) + return coach.analyze(entries, client=client) diff --git a/backtest/src/backtest/coach.py b/backtest/src/backtest/coach.py new file mode 100644 index 0000000..895e013 --- /dev/null +++ b/backtest/src/backtest/coach.py @@ -0,0 +1,189 @@ +"""Claude-powered behavioral-analysis agent (the "coach"). + +Reads the trade journal joined to the market microstructure regime at each entry +(see :mod:`backtest.journal`) and asks Claude to surface recurring behavioral +patterns and emotional biases. It produces *behavioral observations about the +trader's own decisions* — never trading advice and never price predictions. + +The Anthropic client is injected into :func:`analyze` so the analysis logic is +unit-testable with a fake client and no network access. The SDK is imported +lazily in :func:`main` (the CLI) so importing this module does not require the +``anthropic`` package. + +CLI: + python -m backtest.coach [--db PATH] [--limit N] [--load CSV] +""" + +import argparse +import os +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from backtest import journal + +MODEL_ENV = "ANTHROPIC_MODEL" +DEFAULT_MODEL = "claude-opus-4-8" +# Same env var the read API uses (backtest.api.DB_PATH_ENV); duplicated here to +# avoid a circular import, since api imports coach. +DB_PATH_ENV = "METRICS_DB" + +DISCLAIMER = ( + "This is a behavioral analysis of your own trading. It is not financial " + "advice and does not predict prices or market direction." +) + +SYSTEM_PROMPT = f"""\ +You are a trading-behavior analyst. You read a trader's own journal — the trades +they chose to take, their written notes and stated emotions — alongside the +market microstructure regime (order-flow imbalance, realized volatility, VWAP) +that was in force at the moment of entry. + +Your job is to surface recurring behavioral patterns and emotional or cognitive +biases, and to relate them to the regime the trader was acting in — for example +entering into strongly one-sided order flow, sizing up right after a loss, or +hesitating during high-volatility windows. Ground every observation in specific +journal entries. + +You do NOT give trading advice, you do NOT tell the trader what to do next, and +you do NOT predict prices or market direction. You describe behavior, not +markets. + +Populate the 'disclaimer' field with exactly this text: "{DISCLAIMER}" +""" + + +class BehavioralObservation(BaseModel): + """One recurring behavioral pattern found in the journal.""" + + pattern: str = Field(description="Short name for the behavioral pattern.") + bias: str = Field(description="The emotional or cognitive bias involved.") + evidence: str = Field(description="Specific journal entries that support it.") + severity: Literal["low", "medium", "high"] = Field( + description="How strongly the pattern shows up in the journal." + ) + + +class BehavioralAnalysis(BaseModel): + """The agent's structured read of the whole journal.""" + + summary: str = Field(description="One-paragraph overview of the trader's behavior.") + observations: list[BehavioralObservation] = Field( + description="Distinct behavioral patterns, most notable first." + ) + disclaimer: str = Field(description="Verbatim behavioral-analysis disclaimer.") + + +def _model() -> str: + return os.environ.get(MODEL_ENV, DEFAULT_MODEL) + + +def build_messages(entries: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: + """Build the (system, messages) pair sent to Claude for ``entries``.""" + lines = [ + "Here is the trader's own journal. Each entry pairs a trade they took " + "with the market microstructure regime at the moment they entered it.", + "", + ] + lines.extend(_format_entry(entry) for entry in entries) + lines.extend( + [ + "", + "Analyze this journal for recurring behavioral patterns and " + "emotional biases, and how they relate to the regime at entry. Cite " + "specific entries as evidence. Do not give trading advice or predict " + "prices.", + ] + ) + return SYSTEM_PROMPT, [{"role": "user", "content": "\n".join(lines)}] + + +def analyze( + entries: list[dict[str, Any]], + *, + client: Any, + model: str | None = None, +) -> BehavioralAnalysis: + """Return Claude's structured behavioral analysis of ``entries``. + + ``client`` is an Anthropic client (or any object exposing the same + ``messages.parse`` surface), injected so this is testable without network + access. An empty journal short-circuits to an empty analysis rather than + spending an API call. + """ + if not entries: + return BehavioralAnalysis( + summary="No journal entries to analyze yet.", + observations=[], + disclaimer=DISCLAIMER, + ) + system, messages = build_messages(entries) + response = client.messages.parse( + model=model or _model(), + max_tokens=16000, + thinking={"type": "adaptive"}, + system=system, + messages=messages, + output_format=BehavioralAnalysis, + ) + return response.parsed_output + + +def _format_entry(entry: dict[str, Any]) -> str: + return ( + f"#{entry.get('id', '?')} {entry.get('symbol')} {entry.get('side')} " + f"size={entry.get('size')} entry={entry.get('entry_price')} " + f"exit={entry.get('exit_price')} pnl={entry.get('pnl')} " + f"entered_at_ns={entry.get('entered_at_ns')} | " + f"regime: {_format_regime(entry)} | " + f"emotion={entry.get('emotion')!r} notes={entry.get('notes')!r}" + ) + + +def _format_regime(entry: dict[str, Any]) -> str: + if entry.get("regime_ofi") is None: + return "unknown (no metrics for this window)" + return ( + f"ofi={entry.get('regime_ofi')} " + f"realized_volatility={entry.get('regime_realized_volatility')} " + f"vwap={entry.get('regime_vwap')}" + ) + + +def _render(analysis: BehavioralAnalysis) -> str: + lines = [analysis.summary, ""] + for obs in analysis.observations: + lines.append(f"- [{obs.severity}] {obs.pattern} ({obs.bias})") + lines.append(f" {obs.evidence}") + lines.extend(["", analysis.disclaimer]) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Analyze a trade journal with Claude.") + parser.add_argument("--db", default=os.environ.get(DB_PATH_ENV, "metrics.db")) + parser.add_argument("--limit", type=int, default=100) + parser.add_argument( + "--load", + metavar="CSV", + help="seed the journal from a CSV before analyzing", + ) + args = parser.parse_args(argv) + + if args.load: + count = journal.import_csv(args.load, args.db) + print(f"loaded {count} journal entries from {args.load}") + + entries = journal.list_enriched(args.db, args.limit) + if not entries: + print("no journal entries found; add trades or pass --load ") + return + + import anthropic # lazy: keeps the SDK out of the library import path + + analysis = analyze(entries, client=anthropic.Anthropic()) + print(_render(analysis)) + + +if __name__ == "__main__": + main() diff --git a/backtest/src/backtest/journal.py b/backtest/src/backtest/journal.py new file mode 100644 index 0000000..5daca07 --- /dev/null +++ b/backtest/src/backtest/journal.py @@ -0,0 +1,206 @@ +"""Trade journal persistence for the Python layer. + +Unlike the ``metrics`` table — which the Rust engine owns and writes — the trade +journal is authored and consumed by this Python + Claude-agent layer, so this +module owns its schema. Entries live in the same SQLite database as the metrics +(the ``METRICS_DB`` path) so each trade can be joined to the microstructure +regime that was in force when it was entered. + +Everything here is plain ``sqlite3`` and free functions, matching the read layer +in ``backtest.api``. +""" + +import csv +import sqlite3 +import time +from pathlib import Path +from typing import Any + +# Fields the caller supplies for a new entry. The store manages ``id`` (an +# autoincrement surrogate key) and ``created_at_ns`` (insertion time). +_INPUT_COLUMNS = ( + "symbol", + "side", + "entered_at_ns", + "exited_at_ns", + "entry_price", + "exit_price", + "size", + "pnl", + "notes", + "emotion", +) + +# Full column set returned when reading a row back. +_ROW_COLUMNS = ("id", *_INPUT_COLUMNS, "created_at_ns") + +# Regime columns attached to each entry by ``list_enriched``. Present (possibly +# ``None``) on every enriched row so downstream consumers see a stable shape. +_REGIME_COLUMNS = ( + "regime_bucket_start_ns", + "regime_window_ns", + "regime_ofi", + "regime_realized_volatility", + "regime_vwap", +) + +_INT_FIELDS = frozenset({"entered_at_ns", "exited_at_ns", "size"}) +_FLOAT_FIELDS = frozenset({"entry_price", "exit_price", "pnl"}) + +# For each journal entry, pick the metric bucket whose window contains the entry +# time. When several window sizes coexist, prefer the finest (smallest window), +# which describes the regime most tightly. A LEFT JOIN leaves the regime columns +# ``NULL`` when no bucket matches (or the metrics table is empty). +_ENRICHED_QUERY = f""" +SELECT + {", ".join("j." + c for c in _ROW_COLUMNS)}, + r.bucket_start_ns AS regime_bucket_start_ns, + r.window_ns AS regime_window_ns, + r.ofi AS regime_ofi, + r.realized_volatility AS regime_realized_volatility, + r.vwap AS regime_vwap +FROM journal_entries j +LEFT JOIN metrics r ON r.rowid = ( + SELECT m.rowid FROM metrics m + WHERE m.bucket_start_ns <= j.entered_at_ns + AND j.entered_at_ns < m.bucket_start_ns + m.window_ns + ORDER BY m.window_ns ASC, m.bucket_start_ns DESC + LIMIT 1 +) +ORDER BY j.entered_at_ns DESC +LIMIT ? +""" + +_PLAIN_QUERY = ( + f"SELECT {', '.join(_ROW_COLUMNS)} FROM journal_entries ORDER BY entered_at_ns DESC LIMIT ?" +) + + +def init_db(conn: sqlite3.Connection) -> None: + """Create the ``journal_entries`` table if it does not already exist.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS journal_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + entered_at_ns INTEGER NOT NULL, + exited_at_ns INTEGER, + entry_price REAL NOT NULL, + exit_price REAL, + size INTEGER NOT NULL, + pnl REAL, + notes TEXT, + emotion TEXT, + created_at_ns INTEGER NOT NULL + ) + """ + ) + + +def insert_entry(path: str | Path, entry: dict[str, Any]) -> dict[str, Any]: + """Insert a journal entry, returning the stored row (including its ``id``). + + Missing optional fields default to ``None``; the required NOT NULL columns + must be present or SQLite raises ``IntegrityError``. + """ + values = tuple(entry.get(col) for col in _INPUT_COLUMNS) + created_at_ns = time.time_ns() + placeholders = ", ".join(["?"] * (len(_INPUT_COLUMNS) + 1)) + with sqlite3.connect(path) as conn: + conn.row_factory = sqlite3.Row + init_db(conn) + cursor = conn.execute( + f"INSERT INTO journal_entries ({', '.join(_INPUT_COLUMNS)}, created_at_ns) " + f"VALUES ({placeholders})", + (*values, created_at_ns), + ) + stored = conn.execute( + f"SELECT {', '.join(_ROW_COLUMNS)} FROM journal_entries WHERE id = ?", + (cursor.lastrowid,), + ).fetchone() + return dict(stored) + + +def list_entries(path: str | Path, limit: int) -> list[dict[str, Any]]: + """Return journal entries newest first (by entry time). Empty if none yet.""" + return _read(path, _PLAIN_QUERY, limit) + + +def list_enriched(path: str | Path, limit: int) -> list[dict[str, Any]]: + """Return journal entries newest first, each joined to its market regime. + + Every row carries the ``regime_*`` columns; they are ``None`` when the + metrics table is missing/empty or no bucket covers the entry time. This is + the join that lets the agent correlate behavior with the microstructure + context at the time of each trade. + """ + path = Path(path) + if not path.exists(): + return [] + with sqlite3.connect(path) as conn: + conn.row_factory = sqlite3.Row + try: + query = _ENRICHED_QUERY if _has_metrics_table(conn) else _PLAIN_QUERY + rows = conn.execute(query, (limit,)).fetchall() + except sqlite3.OperationalError: + return [] + enriched: list[dict[str, Any]] = [] + for row in rows: + record = dict(row) + for column in _REGIME_COLUMNS: + record.setdefault(column, None) + enriched.append(record) + return enriched + + +def import_csv(csv_path: str | Path, path: str | Path) -> int: + """Bulk-load journal entries from a CSV, returning the number inserted. + + Used to seed the synthetic sample journal. Empty cells become ``None``; + numeric columns are coerced to int/float. + """ + with open(csv_path, newline="") as handle: + rows = list(csv.DictReader(handle)) + created_at_ns = time.time_ns() + placeholders = ", ".join(["?"] * (len(_INPUT_COLUMNS) + 1)) + statement = ( + f"INSERT INTO journal_entries ({', '.join(_INPUT_COLUMNS)}, created_at_ns) " + f"VALUES ({placeholders})" + ) + with sqlite3.connect(path) as conn: + init_db(conn) + for row in rows: + values = tuple(_coerce(col, row.get(col)) for col in _INPUT_COLUMNS) + conn.execute(statement, (*values, created_at_ns)) + return len(rows) + + +def _read(path: str | Path, query: str, limit: int) -> list[dict[str, Any]]: + path = Path(path) + if not path.exists(): + return [] + with sqlite3.connect(path) as conn: + conn.row_factory = sqlite3.Row + try: + rows = conn.execute(query, (limit,)).fetchall() + except sqlite3.OperationalError: + return [] + return [dict(row) for row in rows] + + +def _has_metrics_table(conn: sqlite3.Connection) -> bool: + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'metrics'" + ).fetchone() + return row is not None + + +def _coerce(field: str, raw: str | None) -> Any: + if raw is None or raw == "": + return None + if field in _INT_FIELDS: + return int(raw) + if field in _FLOAT_FIELDS: + return float(raw) + return raw diff --git a/backtest/tests/test_api.py b/backtest/tests/test_api.py index 094786b..6013e8d 100644 --- a/backtest/tests/test_api.py +++ b/backtest/tests/test_api.py @@ -3,7 +3,7 @@ import pytest from fastapi.testclient import TestClient -from backtest import api +from backtest import api, coach COLUMNS = ( "bucket_start_ns, window_ns, buy_volume, sell_volume, total_volume," @@ -90,3 +90,83 @@ def test_empty_database_returns_503(monkeypatch, tmp_path, endpoint): sqlite3.connect(db).close() monkeypatch.setenv(api.DB_PATH_ENV, str(db)) assert TestClient(app=api.app).get(f"/metrics/{endpoint}").status_code == 503 + + +# --- Journal (Phase 3) --------------------------------------------------------- + + +class _FakeResponse: + def __init__(self, parsed_output): + self.parsed_output = parsed_output + + +class _FakeMessages: + def __init__(self, parsed_output): + self._parsed_output = parsed_output + + def parse(self, **kwargs): + return _FakeResponse(self._parsed_output) + + +class _FakeClient: + def __init__(self, parsed_output): + self.messages = _FakeMessages(parsed_output) + + +def _post_entry(client, entered_at_ns=1_500_000_000, **overrides): + body = { + "symbol": "MNQ", + "side": "long", + "entered_at_ns": entered_at_ns, + "entry_price": 21_400.0, + "size": 1, + } + body.update(overrides) + return client.post("/journal", json=body) + + +def test_create_journal_entry(client): + response = _post_entry(client, size=2, notes="plan", emotion="calm") + assert response.status_code == 201 + stored = response.json() + assert stored["id"] == 1 + assert stored["symbol"] == "MNQ" + assert stored["size"] == 2 + assert stored["exit_price"] is None + + +def test_get_journal_joins_regime(client): + # The metrics fixture has a bucket at 1e9 (window 1e9) covering 1.5e9. + _post_entry(client, entered_at_ns=1_500_000_000) + body = client.get("/journal").json() + assert len(body) == 1 + assert body[0]["regime_bucket_start_ns"] == 1_000_000_000 + assert body[0]["regime_ofi"] == 0.5 + + +def test_get_journal_empty_returns_200(client): + assert client.get("/journal").json() == [] + + +def test_journal_limit_is_validated(client): + assert client.get("/journal", params={"limit": 0}).status_code == 422 + + +def test_analyze_journal_returns_structured(client): + _post_entry(client) + canned = coach.BehavioralAnalysis(summary="s", observations=[], disclaimer=coach.DISCLAIMER) + api.app.dependency_overrides[api.get_anthropic_client] = lambda: _FakeClient(canned) + try: + response = client.post("/journal/analyze") + finally: + api.app.dependency_overrides.clear() + assert response.status_code == 200 + body = response.json() + assert body["summary"] == "s" + assert body["disclaimer"] == coach.DISCLAIMER + + +def test_analyze_without_api_key_returns_503(client, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + api.app.dependency_overrides.clear() + assert client.post("/journal/analyze").status_code == 503 diff --git a/backtest/tests/test_coach.py b/backtest/tests/test_coach.py new file mode 100644 index 0000000..c163f2c --- /dev/null +++ b/backtest/tests/test_coach.py @@ -0,0 +1,99 @@ +from backtest import coach +from backtest.coach import BehavioralAnalysis, BehavioralObservation + + +class _FakeMessages: + def __init__(self, parsed_output): + self._parsed_output = parsed_output + self.calls = [] + + def parse(self, **kwargs): + self.calls.append(kwargs) + return _FakeResponse(self._parsed_output) + + +class _FakeResponse: + def __init__(self, parsed_output): + self.parsed_output = parsed_output + + +class FakeClient: + def __init__(self, parsed_output): + self.messages = _FakeMessages(parsed_output) + + +def _canned() -> BehavioralAnalysis: + return BehavioralAnalysis( + summary="Revenge trading after losses.", + observations=[ + BehavioralObservation( + pattern="Revenge sizing", + bias="loss aversion", + evidence="#4, #5", + severity="high", + ) + ], + disclaimer=coach.DISCLAIMER, + ) + + +def _enriched(**overrides): + entry = { + "id": 1, + "symbol": "MNQ", + "side": "long", + "entered_at_ns": 1000, + "entry_price": 21_400.0, + "exit_price": 21_405.0, + "size": 1, + "pnl": 10.0, + "notes": "followed my plan", + "emotion": "calm", + "regime_ofi": 0.5, + "regime_realized_volatility": 0.001, + "regime_vwap": 21_400.0, + } + entry.update(overrides) + return entry + + +def test_analyze_returns_parsed_output(): + canned = _canned() + client = FakeClient(canned) + result = coach.analyze([_enriched()], client=client) + assert result is canned + (call,) = client.messages.calls + assert call["output_format"] is BehavioralAnalysis + assert call["model"] == "claude-opus-4-8" + assert call["thinking"] == {"type": "adaptive"} + + +def test_analyze_uses_model_env(monkeypatch): + monkeypatch.setenv("ANTHROPIC_MODEL", "claude-haiku-4-5") + client = FakeClient(_canned()) + coach.analyze([_enriched()], client=client) + assert client.messages.calls[0]["model"] == "claude-haiku-4-5" + + +def test_analyze_empty_journal_short_circuits(): + client = FakeClient(_canned()) + result = coach.analyze([], client=client) + assert result.observations == [] + assert result.disclaimer == coach.DISCLAIMER + assert client.messages.calls == [] # no API call for an empty journal + + +def test_build_messages_includes_regime_and_notes(): + system, messages = coach.build_messages( + [_enriched(notes="chased the move", emotion="fomo", regime_ofi=-0.5)] + ) + assert "not financial advice" in system.lower() + user = messages[0]["content"] + assert "chased the move" in user + assert "fomo" in user + assert "ofi=-0.5" in user + + +def test_build_messages_marks_unknown_regime(): + _system, messages = coach.build_messages([_enriched(regime_ofi=None)]) + assert "unknown" in messages[0]["content"] diff --git a/backtest/tests/test_journal.py b/backtest/tests/test_journal.py new file mode 100644 index 0000000..8276a94 --- /dev/null +++ b/backtest/tests/test_journal.py @@ -0,0 +1,130 @@ +import sqlite3 + +import pytest + +from backtest import journal + +# Same columns the Rust engine writes; used to seed a metrics table for the join. +METRICS_COLUMNS = ( + "bucket_start_ns, window_ns, buy_volume, sell_volume, total_volume," + " trade_count, ofi, vwap, realized_volatility" +) + + +def _entry(entered_at_ns: int, side: str = "long", **overrides): + entry = { + "symbol": "MNQ", + "side": side, + "entered_at_ns": entered_at_ns, + "entry_price": 21_400.0, + "size": 1, + "exited_at_ns": entered_at_ns + 1_000, + "exit_price": 21_405.0, + "pnl": 10.0, + "notes": "note", + "emotion": "calm", + } + entry.update(overrides) + return entry + + +def _seed_metrics(db, rows): + with sqlite3.connect(db) as conn: + conn.execute(f"CREATE TABLE metrics ({METRICS_COLUMNS})") + conn.executemany(f"INSERT INTO metrics VALUES ({', '.join(['?'] * 9)})", rows) + + +@pytest.fixture +def db(tmp_path): + return tmp_path / "metrics.db" + + +def test_insert_returns_stored_row(db): + stored = journal.insert_entry(db, _entry(1000)) + assert stored["id"] == 1 + assert stored["symbol"] == "MNQ" + assert stored["entered_at_ns"] == 1000 + assert isinstance(stored["created_at_ns"], int) + + +def test_list_entries_newest_first(db): + for entered_at_ns in (1000, 3000, 2000): + journal.insert_entry(db, _entry(entered_at_ns)) + entries = journal.list_entries(db, limit=10) + assert [e["entered_at_ns"] for e in entries] == [3000, 2000, 1000] + + +def test_list_entries_respects_limit(db): + for entered_at_ns in (1000, 2000, 3000): + journal.insert_entry(db, _entry(entered_at_ns)) + assert len(journal.list_entries(db, limit=2)) == 2 + + +def test_list_entries_missing_db_returns_empty(tmp_path): + assert journal.list_entries(tmp_path / "nope.db", limit=10) == [] + + +def test_list_enriched_without_metrics_has_null_regime(db): + journal.insert_entry(db, _entry(1000)) + (row,) = journal.list_enriched(db, limit=10) + assert row["regime_ofi"] is None + assert row["regime_vwap"] is None + assert row["regime_bucket_start_ns"] is None + + +def test_list_enriched_joins_regime(db): + journal.insert_entry(db, _entry(1_500)) + _seed_metrics( + db, + [ + (1000, 1000, 30, 10, 40, 5, 0.5, 21_400.0, 0.001), + (2000, 1000, 5, 15, 20, 4, -0.5, 21_401.0, 0.002), + ], + ) + (row,) = journal.list_enriched(db, limit=10) + assert row["regime_bucket_start_ns"] == 1000 + assert row["regime_window_ns"] == 1000 + assert row["regime_ofi"] == 0.5 + assert row["regime_vwap"] == 21_400.0 + + +def test_list_enriched_prefers_finest_window(db): + journal.insert_entry(db, _entry(1_500)) + _seed_metrics( + db, + [ + (0, 5000, 1, 1, 2, 2, 0.0, 1.0, 0.0), # coarse window also contains 1500 + (1000, 1000, 1, 1, 2, 2, 0.9, 2.0, 0.0), # finer window contains 1500 + ], + ) + (row,) = journal.list_enriched(db, limit=10) + assert row["regime_window_ns"] == 1000 + assert row["regime_ofi"] == 0.9 + + +def test_list_enriched_no_bucket_covers_entry(db): + journal.insert_entry(db, _entry(9_999)) + _seed_metrics(db, [(1000, 1000, 1, 1, 2, 2, 0.5, 1.0, 0.0)]) # covers [1000, 2000) + (row,) = journal.list_enriched(db, limit=10) + assert row["regime_ofi"] is None + + +def test_import_csv(tmp_path): + csv_path = tmp_path / "j.csv" + csv_path.write_text( + "symbol,side,entered_at_ns,exited_at_ns,entry_price,exit_price,size,pnl,notes,emotion\n" + "MNQ,long,1000,2000,21400.00,21405.00,1,10.00,ok,calm\n" + "MNQ,short,3000,,21410.00,,2,,,\n" + ) + db = tmp_path / "metrics.db" + assert journal.import_csv(csv_path, db) == 2 + + newest, oldest = journal.list_entries(db, limit=10) + assert newest["side"] == "short" + assert newest["size"] == 2 + assert newest["exited_at_ns"] is None + assert newest["exit_price"] is None + assert newest["pnl"] is None + assert newest["notes"] is None + assert oldest["entry_price"] == 21_400.0 + assert oldest["pnl"] == 10.0 diff --git a/data/generate_journal.py b/data/generate_journal.py new file mode 100644 index 0000000..3672ee7 --- /dev/null +++ b/data/generate_journal.py @@ -0,0 +1,96 @@ +"""Generate a synthetic trade journal for development and demos. + +All output is synthetic — no real trades, accounts or amounts are involved. Entry +times fall inside the range of the bundled sample tick tape, so each journal +entry joins to a real microstructure bucket. The trades deliberately encode a few +behavioral narratives — revenge trading after losses, FOMO chasing, oversizing +after a big win, hesitation in choppy tape — so the behavioral agent has +something concrete to find. + +Usage: + python data/generate_journal.py [--seed S] [--out PATH] +""" + +import argparse +import csv +import random + +SYMBOL = "MNQ" +POINT_VALUE = 2.0 # MNQ: $2 per index point (synthetic) +START_TIMESTAMP_NS = 1_760_000_000_000_000_000 # matches generate_ticks.py +SPACING_NS = 30_000_000_000 # ~30s between entries, well inside the tape span + +FIELDNAMES = [ + "symbol", + "side", + "entered_at_ns", + "exited_at_ns", + "entry_price", + "exit_price", + "size", + "pnl", + "notes", + "emotion", +] + +# (side, entry_price, exit_price, size, notes, emotion). pnl is derived from the +# prices, size, side and point value so the numbers are internally consistent. +_TRADES = [ + ("long", 21_400.00, 21_405.00, 1, "Waited for the pullback and followed my plan.", "calm"), + ("long", 21_402.50, 21_407.50, 1, "Clean continuation setup, sized normally.", "calm"), + ("long", 21_406.00, 21_402.00, 1, "Stopped out at my level. Part of the game.", "disciplined"), + ("long", 21_403.00, 21_399.50, 3, "Jumped right back in bigger to win it back.", "frustrated"), + ("long", 21_400.50, 21_396.00, 5, "Doubled down again, angry about the last two.", "angry"), + ("short", 21_398.00, 21_401.00, 3, "Chased the breakdown well after it started.", "fomo"), + ("long", 21_401.00, 21_402.00, 1, "Hesitated, finally entered late in choppy tape.", "anxious"), + ("long", 21_404.00, 21_410.00, 2, "Reset, waited, took the A+ setup.", "calm"), + ("short", 21_409.00, 21_402.00, 6, "Felt unstoppable after that win, went big.", "euphoric"), + ("short", 21_403.50, 21_407.50, 6, "Kept the size huge and gave most of it back.", "careless"), + ("long", 21_408.00, 21_405.50, 3, "Everyone was talking about it so I jumped in.", "fomo"), + ("long", 21_405.00, 21_409.50, 1, "Back to my process, small and patient.", "calm"), +] + + +def generate(seed: int) -> list[dict]: + rng = random.Random(seed) + rows = [] + slot_start = START_TIMESTAMP_NS + SPACING_NS + for side, entry_price, exit_price, size, notes, emotion in _TRADES: + entered_at_ns = slot_start + rng.randint(0, 5_000_000_000) # jitter up to ~5s + exited_at_ns = entered_at_ns + rng.randint(2_000_000_000, 20_000_000_000) + direction = 1 if side == "long" else -1 + pnl = (exit_price - entry_price) * direction * size * POINT_VALUE + rows.append( + { + "symbol": SYMBOL, + "side": side, + "entered_at_ns": entered_at_ns, + "exited_at_ns": exited_at_ns, + "entry_price": f"{entry_price:.2f}", + "exit_price": f"{exit_price:.2f}", + "size": size, + "pnl": f"{pnl:.2f}", + "notes": notes, + "emotion": emotion, + } + ) + slot_start += SPACING_NS + return rows + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--seed", type=int, default=7) + parser.add_argument("--out", default="data/sample_journal.csv") + args = parser.parse_args() + + rows = generate(args.seed) + with open(args.out, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=FIELDNAMES) + writer.writeheader() + writer.writerows(rows) + print(f"wrote {len(rows)} journal entries to {args.out}") + + +if __name__ == "__main__": + main() diff --git a/data/sample_journal.csv b/data/sample_journal.csv new file mode 100644 index 0000000..b08ca20 --- /dev/null +++ b/data/sample_journal.csv @@ -0,0 +1,13 @@ +symbol,side,entered_at_ns,exited_at_ns,entry_price,exit_price,size,pnl,notes,emotion +MNQ,long,1760000030647892279,1760000035443634567,21400.00,21405.00,1,10.00,Waited for the pullback and followed my plan.,calm +MNQ,long,1760000064606078771,1760000068907674462,21402.50,21407.50,1,10.00,"Clean continuation setup, sized normally.",calm +MNQ,long,1760000094544070773,1760000103018457962,21406.00,21402.00,1,-8.00,Stopped out at my level. Part of the game.,disciplined +MNQ,long,1760000120161042648,1760000136908438578,21403.00,21399.50,3,-21.00,Jumped right back in bigger to win it back.,frustrated +MNQ,long,1760000150300026767,1760000169869505384,21400.50,21396.00,5,-45.00,"Doubled down again, angry about the last two.",angry +MNQ,short,1760000181823296038,1760000187893674959,21398.00,21401.00,3,-18.00,Chased the breakdown well after it started.,fomo +MNQ,long,1760000211703729684,1760000222191680736,21401.00,21402.00,1,2.00,"Hesitated, finally entered late in choppy tape.",anxious +MNQ,long,1760000244495038384,1760000254477099643,21404.00,21410.00,2,24.00,"Reset, waited, took the A+ setup.",calm +MNQ,short,1760000271243862422,1760000291043302458,21409.00,21402.00,6,84.00,"Felt unstoppable after that win, went big.",euphoric +MNQ,short,1760000304800881088,1760000307577094987,21403.50,21407.50,6,-48.00,Kept the size huge and gave most of it back.,careless +MNQ,long,1760000332744112455,1760000336343547722,21408.00,21405.50,3,-15.00,Everyone was talking about it so I jumped in.,fomo +MNQ,long,1760000364564643895,1760000384000498155,21405.00,21409.50,1,9.00,"Back to my process, small and patient.",calm