Skip to content

curlyjoeyaknow/quantbot-live

Repository files navigation

quantbot-live

Live trading control system. Python-first orchestration with paper-mode execution in V0.

Hard rule: this repo does not contain research code. The research repo (quantBot/) discovers edges and promotes versioned strategy bundles. This repo decides, executes, reconciles, manages positions, and reports.

Repo isolation / scope boundary

quantbot-live is isolated from quantBot:

  • No cross-repo reads, imports, greps, or contract extraction from quantBot are allowed from this repo. The qbl package must not import quantBot / quantbot (machine-enforced by import-linter Rule 1).
  • T-11 caller / OHLCV / scoring work is out of scope here — it must be resolved separately in the quantBot repo.
  • Finalized strategy / caller / OHLCV / scoring contracts may only enter quantbot-live later through an explicit, planned contract extraction/import task — never ad hoc. Until then, the local contracts in packages/contracts/ are self-contained placeholders/mirrors.

Merge process (PR-style review gate)

All task work lands on main through a real GitHub PR review gate: a task branch, a completed review pack (the PR body), and a gh pr merge --mergeno feature work commits directly to main. Merges are blocked on failing tests, broken import-linter Rule 1/2, a dirty working tree (anything but untracked .obsidian/), or unreported scope drift.

Status

V0 paper-mode walking skeleton — COMPLETE (ST-01 → ST-23). Paper-only: no real wallets, no Solana RPC, no signing, no live streams. Fully isolated from the quantBot research repo. The full entry spine — RawAlert → NormalizedAlert → CandidateSignal → Orchestrator → FulfilmentReport → Position → daily report — runs end-to-end on Postgres + Redis with a deep-diff rebuildable serving layer.

Phase 2 (post-V0, in progress): read-only personal Telegram (MTProto) shadow intake (2A); a standalone StrategyDecisionEngine between CandidateSignal and the orchestrator (2B); a fixture-first MarketContextProvider port (2C); and a paper exit / close lifecycle (2D): ExitEvaluator (take profit / stop loss / trailing stop / max hold / manual) → ExitIntent → SELL TradeCommand → PaperExecutor → FulfilmentReport → PositionClosed, with a SELL-aware paper executor, deterministic idempotent closes, and serving-state rebuild + daily report covering opened and closed positions. The execution/fulfilment SOL leg is split into direction-specific fields — actual_sol_spent (BUY) / actual_sol_received (SELL) — ahead of the Rust executor (2D-F). The 2B/2C/2D layers are wired into PaperPipeline as opt-in seams — a decision gate that stops watch_only/rejected candidates before any TradeIntent and sizes the intent via recommended_notional_sol, plus caller-priced run_exits (2D-G). A trailing stop trails a per-position high-water-mark (Position.peak_price_sol), armed at an activation multiple (2D-H). Partial / scaled exits (sell_pct < 100) sell a fraction and leave the position open with a reduced size, via PaperTradeManager.reduce_position + a PositionUpdated / partial_sold producer and rebuild support (2D-I) — so the paper exit engine is complete. A Rust executor port/adapter (RustExecutorClient) speaks a versioned JSON wire protocol over a pluggable transport — adapter only, stub/ fake tested, no live send (2E); a semi-live simulate-only mode (simulate_only, ExecutionStatus.simulated) plus a RecordingExecutorClient logs the full execution lifecycle to ledger.events (2F); and a MarketStreamProvider port + offline recorded adapter + a gated, live-verified Birdeye WebSocket feed (BirdeyeMarketStreamProvider, read-only price observations) land market data behind one thin adapter (2G; live protocol verified by operator 2026-05-27, not wired into the pipeline). Still no live trading and no real funds/signing — the live frontier (pipeline wiring, wallet/sim, micro-live) is gated.

Phase 2D-F1 → 2D-F5d (2026-05-27 → 2026-05-30) — review track complete: the eight fix packs close all ten review findings — simulate_only response guard, partial-fill close handling, peak_price_sol replay + rebuild regression, exit-side ExitAuthorizer gate, Birdeye URL secret redaction, the four rebuild sub-gaps (wallet leases, risk decisions, balances, intents — all now in SUPPORTED_TABLES), reporter proceeds_sol for partials, schema-export completeness guard, and residual doc cleanup. Result: rebuild deep-diff PASS on 7 supported tables, 0 documented gaps and a clean pre-Rust-executor resume point.

Stack

  • Python 3.12 (managed by uv)
  • Pydantic v2, SQLAlchemy 2.x async, structlog
  • Postgres 16 (event ledger + serving state, Alembic migrations)
  • Redis Streams 7 (event bus)
  • pytest, ruff, mypy --strict, import-linter

Quick start

# 1. Bring up Postgres + Redis
docker compose up -d postgres redis
uv sync --all-extras

# 2. Apply all migrations
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run alembic -c infra/migrations/alembic.ini upgrade head

# 3. Seed a deterministic paper demo (register strategy + run 3 sample alerts)
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
QBL_REDIS_URL=redis://localhost:6379/0 \
    uv run python scripts/seed_paper_demo.py

# 4. Inspect what was written
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    psql postgresql://qbl:qbl_dev@localhost:5432/qbl -c \
    "SELECT event_type, count(*) FROM ledger.events GROUP BY event_type ORDER BY 1;"

# 5. Run the full test suite
uv run pytest tests/ -q   # expected: 933 passed

Paper demo seed script

scripts/seed_paper_demo.py seeds a deterministic paper demo state:

  1. Registers PUMPFUN_PAPER_DEMO_V0 from configs/strategies/examples/.
  2. Enables it via StrategyRegistry.set_status().
  3. Runs all 3 sample alerts from tests/fixtures/alerts/sample_alerts.jsonl through the paper pipeline (persist mode — writes to Postgres + Redis).

All artifact IDs are deterministic via UUID5. Running the seed script twice produces identical position IDs and no duplicate rows.

# Dry-run — no DB writes; prints position IDs that would be created:
uv run python scripts/seed_paper_demo.py --dry-run

# With persistence (Postgres + Redis required):
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
QBL_REDIS_URL=redis://localhost:6379/0 \
    uv run python scripts/seed_paper_demo.py

Inspect the seeded state:

psql postgresql://qbl:qbl_dev@localhost:5432/qbl <<'SQL'
-- Ledger events produced by the seed
SELECT event_type, count(*) FROM ledger.events GROUP BY event_type ORDER BY 1;

-- Positions opened
SELECT position_id, token_mint, status, entry_notional_sol
FROM serving.current_positions;

-- Active strategy
SELECT strategy_id, strategy_version, status FROM serving.active_strategy_configs;
SQL

Serving state rebuild

scripts/rebuild_serving_state.py truncates and rebuilds supported serving tables from ledger.events. Use this after data-loss incidents, schema migrations, or to verify that the event ledger is the authoritative source.

Supported rebuild targets:

Table Event source
serving.active_strategy_configs StrategyRegistered + StrategyStatusChanged
serving.active_kill_switches KillSwitchActivated + KillSwitchDeactivated
serving.current_positions PositionOpened

Documented gaps — not rebuilt in V0 (no sufficient events exist):

Table Gap
(none) All previously-documented V0 rebuild gaps are closed as of Phase 2D-F5c (review finding #10 fully closed): wallet_leases_active in 2D-F5a, latest_risk_state in 2D-F5b, current_wallet_balances + open_trade_intents in 2D-F5c. The rebuild script's --assert-identical reports 0 documented gaps.
# Dry-run — read-only, prints what would be rebuilt:
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run python scripts/rebuild_serving_state.py --dry-run

# Rebuild (truncates supported tables and replays from ledger.events):
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run python scripts/rebuild_serving_state.py

# Rebuild + assert the rebuilt state is DEEPLY identical to the pre-drop state
# (full per-column diff of every supported table, not a row-count check;
#  exits non-zero on any drift):
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run python scripts/rebuild_serving_state.py --assert-identical

--assert-identical snapshots every supported table deeply (all columns) before the drop and after the rebuild, then reports per-table PASS ✓ / FAIL ✗ plus a WARN line for each documented V0 gap, and ends with a PASS/FAIL summary. Replay deep-diff coverage lives in tests/replay/. Failure-mode coverage (risk rejection, kill-switch block, lease rejection, expired command, executor exception / simulated adapter timeout, bad fill, idempotent replay, Redis-down recoverability, transaction rollback) lives in tests/failure_modes/.

Daily report

python -m qbl.reporting renders a daily markdown report from ledger.events (position lifecycle + accounting). The report is derived from the append-only ledger — the same truth that rebuilds serving state (Rule 7), never from mutable serving rows. Output goes to reports/daily/YYYY-MM-DD.md (gitignored).

Day scoping uses the domain timestamp (opened_at_utc / closed_at_utc / occurred_at_utc), so a report reflects when things happened, not when the row was written. The renderer is a pure function of its input — identical data produces byte-identical markdown.

# Offline — render an empty report file (no DB):
uv run python -m qbl.reporting --date 2026-05-24

# Persist mode — read events, write file, emit ReportPublished (idempotent):
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
QBL_REDIS_URL=redis://localhost:6379/0 \
    uv run python -m qbl.reporting --date 2026-05-24 --persist

ReportPublished uses a deterministic event_id = uuid5(NS, "daily:{date}"), so re-publishing the same date never creates a duplicate ledger row.

The realized-PnL section lists closed positions as of Phase 2D (the paper exit lifecycle emits PositionClosed). Remaining gap (documented, not a bug): the wallet-balance section still renders a "no producer yet" note — AccountingSnapshotCreated has no producer. The code paths and tables are real and test-covered.

Migration management

All migrations live in infra/migrations/versions/. Apply, roll back, and verify:

# Apply all migrations
QBL_DATABASE_URL=... uv run alembic -c infra/migrations/alembic.ini upgrade head

# Roll back to base (drops all tables — destroys data)
QBL_DATABASE_URL=... uv run alembic -c infra/migrations/alembic.ini downgrade base

# Show current revision
QBL_DATABASE_URL=... uv run alembic -c infra/migrations/alembic.ini current

# Show migration history
QBL_DATABASE_URL=... uv run alembic -c infra/migrations/alembic.ini history

Dev environment runbook

Bring up the supporting services and check both are reachable:

docker compose up -d postgres redis

# Health check (Postgres + Redis)
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
QBL_REDIS_URL=redis://localhost:6379/0 \
    uv run python -m qbl.observability
# expected output:
#   postgres: OK (<latency>ms)
#   redis: OK (<latency>ms)

# Apply migrations
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run alembic -c infra/migrations/alembic.ini upgrade head

# Run the full test suite (uses real Postgres + Redis, not mocks)
uv run pytest tests/ -v

Tests use Redis DB 15 (redis://localhost:6379/15) and truncate ledger.events + serving.active_strategy_configs between cases so they never collide with running services. Tear down with docker compose down -v to wipe the volumes.

Strategy bundle validation + registration

Strategy bundles are YAML files matching the StrategyBundle Pydantic model in packages/contracts/. They are loaded, validated, hashed, and optionally registered into the strategy registry.

Validate a bundle (no DB access — just parses, validates, and prints the deterministic hash):

uv run python -m qbl.strategy_runtime validate \
    configs/strategies/examples/PUMPFUN_PAPER_DEMO_V0.yaml
# strategy_id:      PUMPFUN_PAPER_DEMO_V0
# strategy_version: 0.1.0
# venue_policy:     allowed=['paper']
# bundle_hash:      sha256:...
# status: VALID

Register a bundle (writes to serving.active_strategy_configs and emits a StrategyRegistered event into ledger.events). Registered strategies default to status=proposed (off); pass --enabled to register live:

# Default: status=proposed (off until explicitly enabled)
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run python -m qbl.strategy_runtime register \
    configs/strategies/examples/PUMPFUN_PAPER_DEMO_V0.yaml

# Or register and enable in one step
QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run python -m qbl.strategy_runtime register --enabled \
    configs/strategies/examples/PUMPFUN_PAPER_DEMO_V0.yaml

Registration is idempotent on (strategy_id, strategy_version, bundle_hash) — running the same register twice produces exactly one row and one event. Registering a different content under the same (strategy_id, strategy_version) raises a conflict; bump strategy_version instead.

Alert replay runbook

The alert-monitor package reads JSONL or YAML fixture files and emits AlertReceived, AlertNormalized, and CandidateSignalCreated events. All IDs are content-addressed (UUID5) so replaying the same fixture twice is idempotent.

Dry-run — inspect candidate signals without writing to any service:

uv run python -m qbl.alert_monitor tests/fixtures/alerts/sample_alerts.jsonl
# alert_id=<uuid> token=So111111… signal=caller_alert score=0.90 candidate_id=<uuid> [dry-run]
# alert_id=<uuid> token=EPjFWdd5… signal=caller_alert score=0.75 candidate_id=<uuid> [dry-run]
# alert_id=<uuid> token=So111111… signal=caller_alert score=0.50 candidate_id=<uuid> [dry-run]

Emit events (writes to Postgres ledger.events + Redis Streams):

QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
QBL_REDIS_URL=redis://localhost:6379/0 \
    uv run python -m qbl.alert_monitor tests/fixtures/alerts/sample_alerts.jsonl --emit
# Each alert row: [inserted] on first run, [duplicate] on subsequent runs.

Override strategy candidates:

uv run python -m qbl.alert_monitor fixture.jsonl \
    --strategy MY_STRATEGY_V1 --strategy MY_STRATEGY_V2

Personal Telegram session shadow mode (Phase 2A — read-only): --telegram parses the file as personal-Telegram (MTProto) message events via PersonalTelegramSessionAdapter and runs the same alert spine. The alerts live in private groups only the user's personal account can read, so the Bot API is insufficient — a personal user-session (Telethon/MTProto) is required. Strictly read-only: emits RawAlert/NormalizedAlert/CandidateSignal only, never a TradeIntent/TradeCommand, never wallet/risk/executor, never sends/joins/automates. Session secrets (QBL_TG_*) are SecretStr, env-loaded, gitignored, never logged.

uv run python -m qbl.alert_monitor tests/fixtures/alerts/telegram_messages.jsonl --telegram
# telegram shadow: 2 alert(s), 1 ignored, 0 rejected   (add --emit to write events)

Fixture format — JSONL (one JSON object per line, # lines ignored):

{"source_type": "telegram", "source_id": "chan_42", "observed_at_utc": "2026-05-24T08:00:00+00:00", "token_mint": "So1...", "confidence_score": 0.9, "caller_identity_id": "caller_001"}

Required: source_type, source_id, observed_at_utc (UTC-aware), token_mint, confidence_score. Optional: provider_received_at_utc, caller_identity_id, slot, signature, matched_filters.

YAML format is also supported (top-level list of mappings, .yaml/.yml extension).

Inspect emitted events in the ledger:

QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    psql postgresql://qbl:qbl_dev@localhost:5432/qbl \
    -c "SELECT event_type, created_at_utc FROM ledger.events ORDER BY created_at_utc;"

Kill-switch runbook

Kill switches block new trade entries across 10 scopes. They persist in serving.active_kill_switches (rebuildable from ledger.events).

Activate a global kill switch (blocks all new entries immediately):

QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run python -m qbl.kill_switch activate global_ "emergency stop" operator
# Output: Activated: <uuid>

Verify orchestrator refuses new trades — with any active global kill switch, Orchestrator.run() returns OrchestratorStage.kill_switch_active before building an intent or touching the wallet.

List active kill switches:

QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run python -m qbl.kill_switch list

Deactivate (paste the UUID from activate output):

QBL_DATABASE_URL=postgresql+asyncpg://qbl:qbl_dev@localhost:5432/qbl \
    uv run python -m qbl.kill_switch deactivate <uuid> operator

Scope reference:

Scope target Blocks
global_ all new entries
drawdown all new entries (daily loss cap)
manual all new entries (operator pause)
executor all new entries (execution path down)
rpc all new entries (RPC degraded)
strategy strategy_id matching strategy only
wallet wallet_id matching wallet only
venue venue name (e.g. paper) matching venue only
token base58 mint address matching token only
caller caller id V0: not enforced (no caller context at orchestrator)

Each activation emits a KillSwitchActivated event to ledger.events; each deactivation emits KillSwitchDeactivated. The serving table rebuilds from these events (Rule 7).

Layout

apps/          One process per architectural component (alert-monitor, orchestrator, ...)
packages/      Reusable libraries (contracts, risk-engine, accounting, ...)
infra/         Docker, Alembic migrations, secrets placeholder
configs/       Strategy / risk / wallet / venue YAML configs
reports/       Generated artifacts (gitignored)
tests/         contract/ replay/ integration/ failure_modes/
scripts/       Seed and rebuild helpers

Secrets

.env.example lists required environment variables. Production secrets should be managed with sops/age or a cloud KMS; nothing in this repo contains real keys.

Non-negotiable rules

The 10 rules are enforced by tests/contract/test_non_negotiable_rules.py and the import-linter config in pyproject.toml — violations fail CI, not just review.

quantbot-live

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors