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.
quantbot-live is isolated from quantBot:
- No cross-repo reads, imports, greps, or contract extraction from
quantBotare allowed from this repo. Theqblpackage must not importquantBot/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
quantBotrepo. - Finalized strategy / caller / OHLCV / scoring contracts may only enter
quantbot-livelater through an explicit, planned contract extraction/import task — never ad hoc. Until then, the local contracts inpackages/contracts/are self-contained placeholders/mirrors.
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 --merge —
no 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.
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.
- 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
# 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 passedscripts/seed_paper_demo.py seeds a deterministic paper demo state:
- Registers
PUMPFUN_PAPER_DEMO_V0fromconfigs/strategies/examples/. - Enables it via
StrategyRegistry.set_status(). - Runs all 3 sample alerts from
tests/fixtures/alerts/sample_alerts.jsonlthrough 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.pyInspect 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;
SQLscripts/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/.
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 --persistReportPublished 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.
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 historyBring 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/ -vTests 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 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: VALIDRegister 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.yamlRegistration 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.
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_V2Personal 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 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 listDeactivate (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> operatorScope 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).
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
.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.
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.