Baseline before Postgres migration (WS0)

Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB
rebuild zip, the Windows .venv, and .env are excluded via .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-15 23:39:29 -04:00
commit 007cd95c79
133 files changed
+17321

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
# ============================================================
# Momentum — environment configuration (copy to .env and fill)
# ============================================================
# ---- Alpaca (market data + paper broker) — REQUIRED for live paper trading
ALPACA_API_KEY=
ALPACA_SECRET_KEY=
# Alpaca paper trading endpoint (default is fine)
APCA_API_BASE_URL=https://paper-api.alpaca.markets
# These two aliases are also read by the paper client:
APCA_API_KEY_ID=
APCA_API_SECRET_KEY=
# ---- Schwab (live broker, Milestone 3) — OPTIONAL for paper trading
SCHWAB_MARKET_DATA_APP_KEY=
SCHWAB_MARKET_DATA_APP_SECRET=
SCHWAB_BROKER_APP_KEY=
SCHWAB_BROKER_APP_SECRET=
SCHWAB_REDIRECT_URI=
SCHWAB_TOKEN_PATH=data/schwab_tokens.json
# ---- Optional research / news providers
POLYGON_API_KEY=
FINNHUB_API_KEY=
Alpha_Vantage_Api_Key=
# ============================================================
# Live paper-trading orchestrator (run_live_paper.py)
# ============================================================
# Execution destination: alpaca_paper (safe) or schwab_live (REAL MONEY)
TRADING_EXECUTION_MODE=alpaca_paper
TRADING_EXECUTION_ENABLED=1
TRADING_EXECUTION_INTERVAL_SECONDS=30
TRADING_AUTO_APPROVE=0 # 0 = manual approval in the dashboard, 1 = auto-arm (no approval)
TRADING_MAX_ORDERS_PER_TICK=1
TRADING_MAX_CONCURRENT_POSITIONS=3
TRADING_RISK_PER_TRADE_PCT=0.01 # fraction of equity risked per trade
# --- entry mechanism (Ross-Cameron-style auto-arm; all tunable) ---
TRADING_REWARD_MULTIPLE=2.0 # target = entry + this * (entry - stop); 2R default
TRADING_ENTRY_ORDER_TYPE=limit # limit = rest at trigger (cancellable); market = fill now
TRADING_ENTRY_TIMEOUT_BARS=2 # cancel an unfilled entry after N minutes resting (0 = off)
TRADING_ENTRY_INVALIDATE_PCT=0.0 # cancel if price breaks this far below entry (0 = any break; <0 = off)
TRADING_ENTRY_GUARD_INTERVAL_SECONDS=5 # how often to re-check armed entries vs the LIVE last-trade price
TRADING_MAX_DAILY_LOSS_PCT=0.03 # daily-loss circuit breaker
# Intraday watcher (signal engine)
WATCHER_ENABLED=1
WATCHER_INTERVAL_SECONDS=60
WATCHER_EVENT_DB_PATH=./data/momentum.duckdb
WATCHER_MAX_SYMBOLS=25
WATCHER_MIN_BARS=30
WATCHER_MIN_QUALITY=0.30
# Comma-separated symbols to follow all day. If empty, candidates are
# discovered from whatever has minute bars in market.duckdb for today.
WATCHER_SYMBOLS=AAPL,TSLA,NVDA,AMD,SOFI
# Price band for auto-discovered candidates (Ross Cameron default 1-20).
WATCHER_PRICE_MIN=1
WATCHER_PRICE_MAX=20
# Live minute-bar ingestion (feeds the watcher with active market data)
LIVE_BARS_ENABLED=1
LIVE_BARS_INTERVAL_SECONDS=60
LIVE_BARS_LOOKBACK_MINUTES=120
# Broker state sync
ALPACA_PAPER_SYNC_ENABLED=1
ALPACA_PAPER_SYNC_INTERVAL_SECONDS=60
ACCOUNT_SYNC_ENABLED=0 # Schwab live account sync
ORDER_SYNC_ENABLED=0 # Schwab live order sync
# Dashboard
DASHBOARD_HOST=0.0.0.0
DASHBOARD_PORT=8010
+60
View File
@@ -0,0 +1,60 @@
# Environment variables
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.venv
venv/
ENV/
env/
# Testing
.pytest_cache/
.coverage
htmlcov/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Ruff
.ruff_cache/
# Data that may contain sensitive info — never version runtime data/DBs
data/
*.wal
# Large artifacts / legacy — do not version
momentum-rebuilt.zip
*.zip
archive/
uv.lock
+664
View File
@@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Momentum Trading System - Architecture Diagram</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
color: #e0e0e0;
padding: 40px 20px;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 40px;
font-size: 2.5em;
background: linear-gradient(135deg, #00d4ff, #00ffaa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.diagram {
background: #2d2d44;
border: 2px solid #00d4ff;
border-radius: 12px;
padding: 40px;
margin-bottom: 40px;
position: relative;
overflow-x: auto;
}
svg {
min-width: 100%;
height: auto;
}
.legend {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 30px;
padding: 20px;
background: rgba(0, 212, 255, 0.1);
border-radius: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
}
.legend-box {
width: 30px;
height: 30px;
border-radius: 4px;
flex-shrink: 0;
}
.color-data { background: #ff6b6b; }
.color-process { background: #4ecdc4; }
.color-event { background: #ffd93d; }
.color-storage { background: #6c5ce7; }
.color-output { background: #00d4ff; }
.color-external { background: #95a5a6; }
.section {
margin-bottom: 40px;
}
.section-title {
font-size: 1.3em;
font-weight: 600;
color: #00ffaa;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #00ffaa;
}
.flow-box {
background: #3a3a52;
border: 1px solid #00d4ff;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
line-height: 1.6;
}
.step-number {
display: inline-block;
background: #00d4ff;
color: #1e1e2e;
width: 28px;
height: 28px;
border-radius: 50%;
text-align: center;
line-height: 28px;
font-weight: bold;
margin-right: 10px;
}
code {
background: #1e1e2e;
padding: 2px 6px;
border-radius: 4px;
font-family: "Monaco", "Menlo", monospace;
color: #00ffaa;
}
.arrow {
color: #00d4ff;
margin: 0 5px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
background: #3a3a52;
border-radius: 8px;
overflow: hidden;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #505070;
}
th {
background: #505070;
color: #00ffaa;
font-weight: 600;
}
tr:hover {
background: rgba(0, 212, 255, 0.1);
}
.file-list {
background: #3a3a52;
border-radius: 8px;
padding: 15px;
margin-top: 10px;
}
.file-list li {
margin-left: 20px;
margin-bottom: 5px;
list-style: disc;
}
.file-list code {
background: #1e1e2e;
padding: 2px 6px;
border-radius: 4px;
}
@media (max-width: 768px) {
h1 {
font-size: 1.8em;
}
.diagram {
padding: 20px;
}
.legend {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<h1>⚡ Momentum Trading System Architecture</h1>
<div class="diagram">
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
<!-- Define styles -->
<defs>
<style>
.node-label { font-size: 14px; font-weight: bold; fill: white; }
.arrow { stroke: #00d4ff; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
.arrow-event { stroke: #ffd93d; stroke-width: 2; fill: none; marker-end: url(#arrowhead-yellow); }
.arrow-data { stroke: #ff6b6b; stroke-width: 2; fill: none; marker-end: url(#arrowhead-red); }
</style>
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#00d4ff" />
</marker>
<marker id="arrowhead-yellow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#ffd93d" />
</marker>
<marker id="arrowhead-red" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#ff6b6b" />
</marker>
</defs>
<!-- DATA SOURCES -->
<rect x="50" y="20" width="140" height="60" fill="#ff6b6b" rx="5"/>
<text x="120" y="55" class="node-label" text-anchor="middle">Alpaca IEX Bars</text>
<!-- INGESTION LAYER -->
<rect x="280" y="20" width="160" height="60" fill="#4ecdc4" rx="5"/>
<text x="360" y="55" class="node-label" text-anchor="middle">Ingestion Module</text>
<!-- RESEARCH DB -->
<rect x="530" y="20" width="140" height="60" fill="#6c5ce7" rx="5"/>
<text x="600" y="50" class="node-label" text-anchor="middle">Research DB</text>
<text x="600" y="70" style="font-size: 12px; fill: #ccc; text-anchor: middle;">(market_data)</text>
<!-- EVENT STORE (CENTER) -->
<rect x="480" y="180" width="240" height="80" fill="#6c5ce7" rx="5" stroke="#ffd93d" stroke-width="3"/>
<text x="600" y="210" class="node-label" text-anchor="middle">EVENT STORE</text>
<text x="600" y="230" style="font-size: 12px; fill: #ccc; text-anchor: middle;">(momentum_events.duckdb)</text>
<text x="600" y="250" style="font-size: 11px; fill: #ffd93d; text-anchor: middle;">append-only canonical events</text>
<!-- WATCHER -->
<rect x="50" y="180" width="140" height="80" fill="#4ecdc4" rx="5"/>
<text x="120" y="210" class="node-label" text-anchor="middle">Watcher</text>
<text x="120" y="235" style="font-size: 12px; fill: white; text-anchor: middle;">symbol evaluation</text>
<text x="120" y="255" style="font-size: 12px; fill: white; text-anchor: middle;">state machine</text>
<!-- EXECUTION -->
<rect x="50" y="340" width="140" height="80" fill="#4ecdc4" rx="5"/>
<text x="120" y="370" class="node-label" text-anchor: "middle">Execution Service</text>
<text x="120" y="395" style="font-size: 12px; fill: white; text-anchor: middle;">sizing + approval</text>
<text x="120" y="415" style="font-size: 12px; fill: white; text-anchor: middle;">order submission</text>
<!-- ALPACA PAPER SYNC -->
<rect x="880" y="180" width="140" height="80" fill="#4ecdc4" rx="5"/>
<text x="950" y="210" class="node-label" text-anchor="middle">Alpaca Sync</text>
<text x="950" y="235" style="font-size: 12px; fill: white; text-anchor: middle;">positions, orders,</text>
<text x="950" y="255" style="font-size: 12px; fill: white; text-anchor: middle;">account state</text>
<!-- ALPACA PAPER BROKER -->
<rect x="880" y="340" width="140" height="80" fill="#95a5a6" rx="5"/>
<text x="950" y="370" class="node-label" text-anchor="middle">Alpaca Paper</text>
<text x="950" y="395" style="font-size: 12px; fill: white; text-anchor: middle;">order submission</text>
<text x="950" y="415" style="font-size: 12px; fill: white; text-anchor: middle;">& fills</text>
<!-- DASHBOARD -->
<rect x="800" y="500" width="220" height="80" fill="#00d4ff" rx="5"/>
<text x="910" y="530" class="node-label" text-anchor="middle" fill="#1e1e2e">Dashboard API & UI</text>
<text x="910" y="555" style="font-size: 12px; fill: #1e1e2e; text-anchor: middle;">http://127.0.0.1:8010</text>
<text x="910" y="575" style="font-size: 12px; fill: #1e1e2e; text-anchor: middle;">projections + SSE streaming</text>
<!-- WATCHLIST PROVIDER -->
<rect x="280" y="500" width="160" height="60" fill="#ff6b6b" rx="5"/>
<text x="360" y="535" class="node-label" text-anchor="middle">Watchlist</text>
<!-- ARROWS -->
<!-- Alpaca → Ingestion -->
<path d="M 190 50 L 280 50" class="arrow-data"/>
<!-- Ingestion → Research DB -->
<path d="M 440 50 L 530 50" class="arrow-data"/>
<!-- Research DB → Watcher -->
<path d="M 530 80 L 300 220" class="arrow-data"/>
<!-- Watcher → Event Store -->
<path d="M 190 220 L 480 220" class="arrow-event"/>
<!-- Event Store → Alpaca Sync (feedback) -->
<path d="M 720 220 L 880 220" class="arrow-event"/>
<!-- Alpaca Sync → Alpaca Paper Broker -->
<path d="M 950 260 L 950 340" class="arrow"/>
<!-- Alpaca Paper Broker → Alpaca Sync (fills feedback) -->
<path d="M 920 340 L 920 260" class="arrow-data" stroke-dasharray="5,5"/>
<!-- Watcher also submits execution requests → Execution Service -->
<path d="M 120 260 L 120 340" class="arrow-event"/>
<!-- Execution → Alpaca Paper Broker -->
<path d="M 190 380 L 880 380" class="arrow"/>
<!-- Event Store → Dashboard -->
<path d="M 600 260 L 910 500" class="arrow-event"/>
<!-- Watchlist → Watcher -->
<path d="M 360 500 L 200 260" class="arrow-data" stroke-dasharray="5,5"/>
</svg>
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-box color-data"></div>
<span><strong>Data Flow:</strong> Market data, bars, watchlist</span>
</div>
<div class="legend-item">
<div class="legend-box color-process"></div>
<span><strong>Process:</strong> Computation & evaluation</span>
</div>
<div class="legend-item">
<div class="legend-box color-event"></div>
<span><strong>Events:</strong> Canonical state changes</span>
</div>
<div class="legend-item">
<div class="legend-box color-storage"></div>
<span><strong>Storage:</strong> Persistent data</span>
</div>
<div class="legend-item">
<div class="legend-box color-output"></div>
<span><strong>Output:</strong> User-facing interface</span>
</div>
<div class="legend-item">
<div class="legend-box color-external"></div>
<span><strong>External:</strong> Broker APIs</span>
</div>
</div>
<!-- DETAILED FLOW SECTION -->
<div class="section">
<div class="section-title">📊 Complete Processing Flow (Every Tick)</div>
<div class="flow-box">
<span class="step-number">1</span>
<strong>INGEST</strong> <span class="arrow">→</span>
<code>ingest_live_minute_bars()</code> +
<code>ingest_daily_history()</code>
<br/><small>Latest 1-minute bars from Alpaca IEX into research/market.duckdb + daily backfill for gap% and RVOL calculations</small>
</div>
<div class="flow-box">
<span class="step-number">2</span>
<strong>DISCOVER</strong> <span class="arrow">→</span>
<code>ResearchWatchlistProvider.get_candidates()</code>
<br/><small>Pull symbols from watchlist (manual, screener, or most-actives from Alpaca)</small>
</div>
<div class="flow-box">
<span class="step-number">3</span>
<strong>WATCH</strong> <span class="arrow">→</span>
<code>Watcher.tick()</code>
<br/><small>For each symbol: fetch bars from research DB, evaluate 9 setup criteria, emit symbol_discovered / state_changed events</small>
</div>
<div class="flow-box">
<span class="step-number">4</span>
<strong>EVALUATE</strong> <span class="arrow">→</span>
<code>evaluate_setup()</code>
<br/><small>Pure strategy logic: gap%, RVOL, bull-flag structure, price action quality. Emit criteria_evaluated events.</small>
</div>
<div class="flow-box">
<span class="step-number">5</span>
<strong>SIGNAL</strong> <span class="arrow">→</span>
<code>Watcher</code> emits <code>signal_ready</code>
<br/><small>Symbol passes ≥60% of criteria (tunable). Debounced (once per session unless it falls back to blocked).</small>
</div>
<div class="flow-box">
<span class="step-number">6</span>
<strong>EXECUTE</strong> <span class="arrow">→</span>
<code>TradingExecutionService.tick()</code>
<br/><small>On signal_ready: position sizing → risk checks (max concurrent, daily loss circuit breaker) → order_approval_requested event</small>
</div>
<div class="flow-box">
<span class="step-number">7</span>
<strong>APPROVE</strong> <span class="arrow">→</span>
<code>Dashboard</code> or <code>--auto-approve</code>
<br/><small>Manual: click Approve in dashboard. Auto: env flag TRADING_AUTO_APPROVE=1. Emit order_approved event.</small>
</div>
<div class="flow-box">
<span class="step-number">8</span>
<strong>SUBMIT</strong> <span class="arrow">→</span>
<code>AlpacaPaperExecutor.submit()</code>
<br/><small>Bracket order: entry (limit or market), profit target (entry + reward_multiple × risk), stop loss. Emit order_submitted event.</small>
</div>
<div class="flow-box">
<span class="step-number">9</span>
<strong>SYNC</strong> <span class="arrow">→</span>
<code>AlpacaPaperSync.sync_account()</code>
<br/><small>Poll Alpaca: account balance, open positions, pending orders. Emit account_summary_updated, account_positions_updated, account_orders_updated events.</small>
</div>
<div class="flow-box">
<span class="step-number">10</span>
<strong>PROJECT</strong> <span class="arrow">→</span>
<code>Dashboard (SSE stream)</code>
<br/><small>Read-only projections over event store: P&L, open positions, approval queue, activity feed, sparkline charts. Fall back to polling if SSE drops.</small>
</div>
</div>
<!-- KEY MODULES -->
<div class="section">
<div class="section-title">🔧 Key Modules & Responsibilities</div>
<table>
<thead>
<tr>
<th>Module</th>
<th>Responsibility</th>
<th>Key Files</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>research/ingestion</strong></td>
<td>Ingest market data (bars, daily history), discover candidates, classify market sessions</td>
<td>market_data.py, watcher_task.py, scheduler.py</td>
</tr>
<tr>
<td><strong>research/</strong></td>
<td>Query and project research databases (minute/daily bars, gappers, RSS news)</td>
<td>query.py, schema.py, multi_schema.py</td>
</tr>
<tr>
<td><strong>runtime/watcher</strong></td>
<td>Symbol state machine: discovered → watching → ready | blocked. Emit all watcher events.</td>
<td>watcher.py</td>
</tr>
<tr>
<td><strong>strategy/evaluation</strong></td>
<td>Pure setup evaluation: 9 criteria (gap%, RVOL, bull-flag, structure, quality, etc.)</td>
<td>setup_evaluator.py</td>
</tr>
<tr>
<td><strong>strategy/risk</strong></td>
<td>Position sizing, risk per trade, reward multiple, daily loss circuit breaker</td>
<td>position_sizing.py</td>
</tr>
<tr>
<td><strong>strategy/backtest</strong></td>
<td>Intraday backtest engine: replay bars, simulate entry/exit, calculate P&L</td>
<td>engine.py</td>
</tr>
<tr>
<td><strong>storage/event_store</strong></td>
<td>Append-only event log (canonical source of truth). Correlate by session_id.</td>
<td>event_store.py, event_schema.py</td>
</tr>
<tr>
<td><strong>storage/projections</strong></td>
<td>Query event store for current state: approval queue, open positions, P&L, etc.</td>
<td>projections.py</td>
</tr>
<tr>
<td><strong>alpaca_paper/</strong></td>
<td>Alpaca paper broker integration: order submission, position sync, account state</td>
<td>client.py, execution.py, sync.py</td>
</tr>
<tr>
<td><strong>schwab/</strong></td>
<td>Schwab live integration (OAuth, market data, positions, orders, health)</td>
<td>oauth.py, market_data.py, positions.py, health.py</td>
</tr>
<tr>
<td><strong>api/</strong></td>
<td>Dashboard JSON API + SSE streaming. Read-only projections served as REST + events.</td>
<td>main.py, static/</td>
</tr>
<tr>
<td><strong>trading_execution.py</strong></td>
<td>Signal → sizing → approval → order pipeline. Manages armed entries & timeouts.</td>
<td>trading_execution.py</td>
</tr>
</tbody>
</table>
</div>
<!-- EVENT FLOW -->
<div class="section">
<div class="section-title">📨 Key Event Types (Event-Sourced)</div>
<table>
<thead>
<tr>
<th>Event</th>
<th>Emitted By</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>symbol_discovered</code></td>
<td>Watcher</td>
<td>New symbol added to watchlist</td>
</tr>
<tr>
<td><code>symbol_state_changed</code></td>
<td>Watcher</td>
<td>Symbol transitions: discovered → watching → ready | blocked</td>
</tr>
<tr>
<td><code>criteria_evaluated</code></td>
<td>Watcher (via evaluate_setup)</td>
<td>9 setup criteria evaluated for symbol (pass/fail/not-evaluated)</td>
</tr>
<tr>
<td><code>signal_ready</code></td>
<td>Watcher</td>
<td>Symbol ready: ≥60% criteria passed. Debounced once per session.</td>
</tr>
<tr>
<td><code>signal_blocked</code></td>
<td>Watcher</td>
<td>Symbol failed criteria check. Resets debounce.</td>
</tr>
<tr>
<td><code>order_approval_requested</code></td>
<td>TradingExecutionService</td>
<td>Ready signal → sizing + risk checks passed. Awaiting approval or auto-submit.</td>
</tr>
<tr>
<td><code>order_approved</code></td>
<td>Dashboard (manual) or TradingExecutionService (auto)</td>
<td>Approval given. Next: submit to broker.</td>
</tr>
<tr>
<td><code>order_rejected</code></td>
<td>Dashboard (manual) or TradingExecutionService (risk rule)</td>
<td>Approval denied or risk rule triggered. Signal dequeued.</td>
</tr>
<tr>
<td><code>order_submitted</code></td>
<td>AlpacaPaperExecutor</td>
<td>Bracket order sent to Alpaca (entry + target + stop).</td>
</tr>
<tr>
<td><code>account_positions_updated</code></td>
<td>AlpacaPaperSync</td>
<td>Open positions snapshot from Alpaca. Includes entry, qty, P&L.</td>
</tr>
<tr>
<td><code>account_orders_updated</code></td>
<td>AlpacaPaperSync</td>
<td>Pending orders snapshot from Alpaca. Tracks fills, cancellations, expirations.</td>
</tr>
<tr>
<td><code>account_summary_updated</code></td>
<td>AlpacaPaperSync</td>
<td>Account balance, buying power, margin, NAV, realized P&L.</td>
</tr>
</tbody>
</table>
</div>
<!-- ENTRY POINT -->
<div class="section">
<div class="section-title">🚀 Main Entry Point: run_live_paper.py</div>
<div class="flow-box">
<strong>Overall orchestrator that ties everything together:</strong>
<ol style="margin-left: 20px; margin-top: 10px;">
<li>Parse CLI args (--once, --symbols, --auto-approve, --no-dashboard)</li>
<li>Load .env and configure Alpaca client + settings</li>
<li>Initialize EventStore (momentum_events.duckdb)</li>
<li>Open ResearchConnection (market_data.duckdb) + ResearchWatchlistProvider</li>
<li>Create Watcher + TradingExecutionService + AlpacaPaperSync + AlpacaPaperExecutor</li>
<li>Optional: start DashboardAPI server (SSE + JSON endpoints) in background thread</li>
<li>Main loop (or single pass with --once):
<ul style="margin-top: 5px;">
<li>Ingest latest minute bars + daily history</li>
<li>Watcher.tick() → symbol evaluation + state changes + signal emissions</li>
<li>TradingExecutionService.tick() → approval requests + submissions</li>
<li>AlpacaPaperSync.sync_account() → positions + orders + account state</li>
<li>Sleep (configurable interval, e.g., 60 seconds)</li>
</ul>
</li>
</ol>
</div>
</div>
<!-- TYPICAL ISSUE DEBUGGING CHECKLIST -->
<div class="section">
<div class="section-title">🔍 Debugging: Where to Look When Something Breaks</div>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Likely Cause</th>
<th>Check</th>
</tr>
</thead>
<tbody>
<tr>
<td>No symbols appearing in Watcher</td>
<td>Watchlist provider not returning candidates</td>
<td>ResearchWatchlistProvider.get_candidates() → is research DB populated? Check data/research/market.duckdb</td>
</tr>
<tr>
<td>Symbols discovered but never go ready</td>
<td>Criteria evaluation failing or threshold too high</td>
<td>Watcher → criteria_evaluated events. Check setup_evaluator.py scores. Lower ready_score_pct or min_quality config.</td>
</tr>
<tr>
<td>Signal ready but no approval request</td>
<td>TradingExecutionService.tick() not running or risk rule triggered</td>
<td>Check trading_execution.py: max_concurrent_positions, max_daily_loss_pct. Emit risk_rule_triggered? Check event store.</td>
</tr>
<tr>
<td>Approval request but order never submitted</td>
<td>Dashboard approval not clicked OR auto-approve not set</td>
<td>Dashboard approval queue visible? Or set TRADING_AUTO_APPROVE=1 in .env</td>
</tr>
<tr>
<td>Order submitted but no fills from Alpaca</td>
<td>API keys missing or Alpaca API down</td>
<td>Check ALPACA_API_KEY / ALPACA_SECRET_KEY in .env. Run --once to test without loop.</td>
</tr>
<tr>
<td>Dashboard not updating (SSE fails)</td>
<td>Network drop or event stream blocked</td>
<td>Dashboard falls back to polling. Check http://127.0.0.1:8010/api/events (should return JSON). Check browser console.</td>
</tr>
<tr>
<td>Backtests showing different P&L than live</td>
<td>Bar data mismatch or backtest engine using different fills</td>
<td>Backtest engine simulates limit fills at exact prices. Live uses actual Alpaca fills. Compare bars in research DB vs Alpaca API.</td>
</tr>
</tbody>
</table>
</div>
<!-- DATA STORAGE LOCATIONS -->
<div class="section">
<div class="section-title">💾 Data Storage Locations</div>
<div class="flow-box">
<strong>Event Store (Canonical Source of Truth)</strong><br/>
<code>data/momentum_events.duckdb</code> — append-only events, indexed by timestamp + session_id
</div>
<div class="flow-box">
<strong>Research Database (Market Data)</strong><br/>
<code>data/research/market.duckdb</code> — minute bars + daily bars ingested from Alpaca
</div>
<div class="flow-box">
<strong>Schwab Integration</strong><br/>
<code>data/schwab_tokens.json</code> — OAuth tokens (git-ignored, 0600 perms)
</div>
<div class="flow-box">
<strong>Configuration</strong><br/>
<code>.env</code> — API keys, trading settings (git-ignored)
</div>
</div>
</div>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
# Comprehensive Backtest Guide
This guide will help you run a multi-week backtest to assess the profitability and risk profile of the momentum strategy before trading live.
## Quick Start (Windows Terminal)
Run a 1-week backtest on small-cap momentum names (the strategy's sweet spot):
```powershell
uv run comprehensive_backtest.py --start 2026-06-05 --end 2026-06-12
```
This analyzes every trading day in that range, pulling minute bars for the specified symbols and reporting:
- **Daily P&L** — win/loss pattern by day
- **Symbol breakdown** — which tickers traded well/poorly
- **Trade-by-trade details** — entry/exit, profit/loss, reason for exit
- **Statistical summary** — win rate, average R-multiple, profit factor, risk metrics
- **Loss analysis** — patterns in losses (gap-throughs, wide stops, etc.)
## What the Output Tells You
### Summary Metrics
- **Win Rate**: % of trades that were profitable. 40%+ is acceptable; 50%+ is strong.
- **Profit Factor**: Total wins ÷ absolute value of total losses.
- < 1.3: marginal edge or slippage issues (investigate before live)
- 1.3–2.0: decent edge
- > 2.0: strong edge
- **Avg R-Multiple**: Average return per unit of risk. 1.5+ suggests the strategy rewards you for the risk taken.
- **Max Loss**: Largest single loss. If it's 2–3× worse than your average loss, stops may be too wide or slippage is severe.
### Red Flags to Watch For
1. **Profit factor < 1.3** — your losses are eating most of your wins
2. **Win rate < 40%** — even with 2:1 R ratio, this struggles to be profitable
3. **Max loss >> average loss** — suggests gap-throughs or wide stops on losers
4. **Inconsistent daily results** — if one bad day wipes out a week's gains, volatility is too high
## Advanced Options
```powershell
# Test a different date range (e.g., last 2 weeks)
uv run comprehensive_backtest.py --start 2026-05-27 --end 2026-06-12
# Test on your watched symbols instead of the defaults
uv run comprehensive_backtest.py --start 2026-06-05 --end 2026-06-12 --symbols SNDL,MARA,RIOT
# Test a full month (if available)
uv run comprehensive_backtest.py --start 2026-05-13 --end 2026-06-12
```
## Important Caveats
1. **This is NOT the live execution path** — the backtest engine shows signal→entry→exit on historical bars, but it does NOT exercise:
- The new auto-arm resting-limit logic
- The tick-by-tick entry invalidation (live prices)
- The approval queue or manual trading decisions
All of those features only run in `uv run run_live_paper.py --auto-approve`. This backtest proves the signal-generation and position-management logic works; it's not a full replay of the live system.
2. **IEX feed gaps** — free tier only sees IEX trades (a subset of consolidated). On illiquid small-caps there may be minutes with no prints, causing simulated fills to happen on stale data. The paid SIP feed (ALPACA_DATA_FEED=sip) would see more ticks.
3. **Minute-bar vs tick-by-tick** — the backtest assumes fills happen at the bar open of the next bar. Live, entries/exits are much more granular (actual filled prices as they happen). This is usually conservative (against you) but can help on some exits.
4. **Slippage assumptions** — the engine includes configurable slippage (base_spread_pct in config). The default assumes ~0.05% slippage; real fills vary by size, liquidity, and market conditions.
## Interpretation Guide
### Strong Strategy (Should Trade)
- Win rate ≥ 45%
- Profit factor ≥ 1.8
- Avg R ≥ 1.5R
- Max loss ≤ 2× average loss
- Consistent week-to-week results
### Marginal Strategy (Investigate Further)
- Win rate 40–45%
- Profit factor 1.3–1.8
- Avg R ≈ 1.0–1.5R
- Max loss 2–3× average loss
- High daily variance
### Weak Strategy (Do Not Trade)
- Win rate < 40%
- Profit factor < 1.3
- Avg R < 0.5R
- Max loss > 3× average loss
- Losing weeks common
## Next Steps
1. **Run the backtest** for the most recent 2–4 weeks using small-cap symbols (SNDL, MARA, RIOT, BBAI, SOUN, PLUG).
2. **Review the loss analysis** — click "Loss Analysis" section and understand why you're losing. Is it:
- Entries are wrong (enter on fakeouts)? → Tighten entry criteria
- Stops are too wide (gap-throughs)? → Use a tighter stop or accept wider slippage
- Exits are leaving money? → Adjust target or exit rules
3. **Check the daily pattern** — if one symbol dominates losses, exclude it (maybe illiquid on IEX).
4. **If strong** → uv run run_live_paper.py --auto-approve on the next trading day.
5. **If marginal/weak** → dig into the top losers to understand the pattern before risking real capital.
## Debugging a Bad Backtest
If you see 0 trades despite seeing signals:
```powershell
# First, make sure minute bars were fetched
uv run fetch_minute_bars.py SNDL MARA RIOT --lookback 5000
# Then try a single-symbol, single-day backtest to see detailed output
uv run backtest_cli.py --symbol SNDL --date 2026-06-12
```
If you get "no symbols with minute bars," the date may not have tradeable data (check it's a weekday) or the lookback was too short.
## Questions?
- The backtest engine: `strategy/backtest/engine.py`
- Setup evaluation (what defines a "signal"): `strategy/evaluation/setup_evaluator.py`
- Risk/position sizing: `strategy/risk/position_sizing.py`
- Config defaults: `config.py` (BacktestConfig section)
+507
View File
@@ -0,0 +1,507 @@
# Complete Build Summary & Implementation Roadmap
## What You Asked For
> "I can't see what's happening. I don't know if bars are being collected. I can't verify research data is accurate. I can't test each module independently. The UI doesn't help debug. And I need a better visualization."
---
## What Was Built (Phase 1 ✅)
### Core Infrastructure
**1. Module Contracts** (`core/contracts.py`)
- Defined 5 typed I/O contracts (ingestion, research, evaluation, execution, broker)
- Every module now has explicit `XyzRequest` (input) and `XyzResult` (output) types
- IDE autocomplete friendly
- Makes bugs obvious when contracts don't match
**2. Telemetry Collection** (`core/telemetry.py`)
- `TelemetryCollector`: Record metrics during module execution
- `TelemetryEmitter`: Emit events to event store + callbacks
- `ModuleMetrics`: Standard metrics (duration, counts, custom fields)
- Global emitter instance for easy access
**3. Data Validation** (`core/validation.py`)
- `SchemaValidator`: Check required fields and types
- `RangeValidator`: Numeric bounds checking
- `ChecksumValidator`: Data integrity verification
- `BusinessRuleValidator`: Custom validation logic
- `BarDataValidator`: Pre-built OHLCV validation
**4. New Event Types** (`storage/event_schema.py`, extended)
- `ModuleTickEvent`: Module execution telemetry
- `DataValidationEvent`: Data quality checkpoints
- `SymbolEvaluationDetailEvent`: Deep-dive symbol metrics
---
## Complete 8-Phase Implementation Plan
### ✅ Phase 1: Core Infrastructure (DONE)
**Time:** ~2 hours
**Status:** Complete
**Deliverables:**
- Core contracts and validation framework
- Telemetry collection and emission
- 3 new event types in event store
- Documentation of all new types
**Why it matters:** Foundation for everything else. Modules can now be transparent.
---
### 📍 Phase 2: Module Instrumentation (NEXT)
**Estimated time:** ~4 hours
**What it does:** Add telemetry to existing modules
**Work:**
1. Wrap each module with `TelemetryCollector`
2. Emit `ModuleTickEvent` on completion
3. Add validation checkpoints with `DataValidationEvent`
4. Emit `SymbolEvaluationDetailEvent` during evaluation
5. Update `run_live_paper.py` to initialize telemetry
**Example output:**
```
ModuleTickEvent: ingestion completed in 245ms, 500 rows
ModuleTickEvent: research completed in 156ms, 5 symbols
SymbolEvaluationDetailEvent: AAPL gap_checked (2.1%)
SymbolEvaluationDetailEvent: AAPL rvol_calculated (1.2x)
DataValidationEvent: evaluation schema valid
```
**Files to modify:**
- `research/ingestion/market_data.py` — add ingestion telemetry
- `research/module.py` — add research telemetry
- `strategy/evaluation/setup_evaluator.py` — add evaluation telemetry + detail events
- `trading_execution.py` — add execution telemetry
- `alpaca_paper/sync.py` — add broker sync telemetry
- `run_live_paper.py` — initialize emitter, pass to modules
**Tests:** Run existing tests to ensure telemetry doesn't break anything
---
### 🔲 Phase 3: Inspection Dashboard API
**Estimated time:** ~6 hours
**What it does:** Build backend REST API for module data
**Files to create:**
- `api/inspection.py` — new API endpoints
**Endpoints:**
```
GET /api/inspection/modules → module metrics table
GET /api/inspection/pipeline → pipeline flow stats
GET /api/inspection/symbol/:symbol → detailed eval timeline
GET /api/inspection/data-quality → validation report
GET /api/inspection/events → SSE stream of telemetry
GET /api/inspection/module/:module/history → last N executions
```
**Projections to build** (`storage/projections.py`, add new functions):
```python
def query_module_performance() → [{module, avg_latency, last_run, error_rate}]
def query_symbol_evaluation_detail(symbol) → {stages with metrics}
def query_data_quality_snapshot() → {validation stats, freshness}
def query_pipeline_stats(session_id) → {module timings, input/output counts}
```
**Tests:**
- Test each endpoint returns correct data
- Test projections handle missing data gracefully
- Test SSE streaming with multiple clients
---
### 🔲 Phase 4: Web Dashboard Frontend
**Estimated time:** ~8 hours
**What it does:** Build React SPA for visualization
**Files to create:**
```
api/inspection/
├── index.html # Single-page app shell
├── app.js # React app entry
├── components/
│ ├── PipelineDiagram.js # Mermaid auto-layout diagram
│ ├── ModuleGrid.js # Performance metrics table
│ ├── SymbolInspector.js # Search + expandable criteria
│ ├── EventTimeline.js # Live event log with filtering
│ └── DataQualityReport.js # Validation statistics
└── styles/
└── dashboard.css # Responsive styling
```
**Features:**
- Real-time SSE updates
- Interactive symbol search
- Expandable evaluation stages
- Sparkline charts (recharts)
- Event timeline with filtering
- Data quality heatmap
- Mobile responsive
**Design:**
- Dark theme (consistent with trading terminals)
- One symbol per row, expandable details
- Color coding (✓ green, ✗ red, ? gray)
- Click to drill down into metrics
**Tests:**
- Component rendering tests
- SSE connection/fallback tests
- Real-world data from phase 3
---
### 🔲 Phase 5: Module Refactoring
**Estimated time:** ~6 hours
**What it does:** Modularize existing code into explicit contracts
**Files to create:**
```
modules/
├── ingestion.py # BarIngestRequest → BarIngestResult
├── research.py # ResearchRequest → ResearchResult
├── evaluation.py # EvaluationRequest → EvaluationResult
├── execution.py # ExecutionRequest → ExecutionResult
└── broker.py # BrokerRequest → BrokerResult
```
**Pattern (example for evaluation):**
```python
class EvaluationModule:
def run(self, request: EvaluationRequest) -> EvaluationResult:
"""Pure function: request → result."""
# Validate input
is_valid, failures = self.validate_input(request)
# Execute strategy logic
result = evaluate_setup(
bars_df=request.bars_df,
symbol=request.symbol,
previous_close=request.previous_close,
avg_daily_volume=request.avg_daily_volume
)
# Return strongly-typed result
return EvaluationResult(
symbol=request.symbol,
status=result['status'],
score=result['score'],
criteria=result['criteria'],
validation_failures=failures
)
```
**Benefits:**
- Each module is a pure function: `input → output`
- Can test in isolation
- Can swap implementations (real vs. mock)
- Dependency injection friendly
**Dependency injection (trait-style):**
```python
class EvaluationModule:
def __init__(self, research_db: ResearchDB = None, ...):
self.research_db = research_db or RealResearchDB()
# In tests:
module = EvaluationModule(research_db=MockResearchDB())
```
**Tests:**
- Unit test each module in isolation
- Integration test module chain
- Contract compliance tests
---
### 🔲 Phase 6: Module Testing Framework
**Estimated time:** ~5 hours
**What it does:** Write comprehensive test suite
**Files to create:**
```
tests/
├── modules/
│ ├── conftest.py # fixtures
│ ├── test_ingestion_module.py # 8 tests
│ ├── test_research_module.py # 6 tests
│ ├── test_evaluation_module.py # 10 tests
│ ├── test_execution_module.py # 8 tests
│ ├── test_broker_module.py # 8 tests
│ └── fixtures/
│ ├── bull_flag_bars.py # Synthetic test data
│ ├── fading_bars.py
│ ├── gap_up_bars.py
│ └── low_volume_bars.py
│
├── integration/
│ ├── test_pipeline_e2e.py # Full flow test
│ ├── test_signal_flow.py # Signal lifecycle
│ └── test_event_consistency.py # Audit trail
│
└── scenarios/
├── bull_flag_setup.py # Golden path
├── fading_tape_blocked.py # Negative case
└── risk_rule_rejection.py # Execution blocker
```
**Test patterns:**
```python
def test_evaluation_module_ready():
"""Module accepts input, produces output."""
module = EvaluationModule()
request = EvaluationRequest(
symbol="AAPL",
bars_df=bull_flag_bars(),
previous_close=150.0,
avg_daily_volume=1_000_000,
session_date=date(2026, 6, 15)
)
result = module.run(request)
assert result.status == "ready"
assert result.score >= 60
assert result.entry_price > 0
# ...
def test_evaluation_module_dry_run():
"""Module works without database."""
module = EvaluationModule(research_db=MockResearchDB())
# Same test as above, but doesn't touch real DB
def test_evaluation_module_validation():
"""Module validates input, captures errors."""
module = EvaluationModule()
request = EvaluationRequest(
symbol="AAPL",
bars_df=invalid_bars(), # Zero volume bar
# ...
)
result = module.run(request)
assert not result.success
assert len(result.validation_failures) > 0
```
**Run tests:**
```bash
pytest tests/modules/ # All module tests
pytest tests/modules/test_evaluation_module.py -v # Specific module
pytest tests/integration/ # Full pipeline
```
---
### 🔲 Phase 7: Dry-Run Mode & Synthetic Data
**Estimated time:** ~4 hours
**What it does:** Add `--dry-run` flag for testing without APIs
**Files to create:**
```
tests/
├── synthetic.py # Bar generators
│ ├── bull_flag_bars() # Ideal setup
│ ├── fading_bars() # Will block
│ ├── gap_up_bars() # Gap but low volume
│ └── spike_bars() # Volume spike
│
└── providers/
├── mock_research_db.py # In-memory research DB
├── mock_broker.py # Simulated fills
└── mock_bars_provider.py # Synthetic bars
core/
└── test_mode.py # DryRun context manager
```
**Synthetic data generators:**
```python
def bull_flag_bars(high_price=150.0, low_price=145.0, bars=50):
"""Generate realistic bull-flag OHLCV bars."""
# - Gap up on open
# - Initial breakout
# - Pullback formation
# - Higher lows pattern
# High volume throughout
# Returns: DataFrame
```
**Usage:**
```bash
# Dry-run mode: no API keys, no real orders
python run_live_paper.py --dry-run \
--symbols AAPL,TSLA,AMD \
--bars-provider synthetic:bull_flag \
--num-ticks 5 \
--inspection-dashboard
# Output:
# Tick 1: ingestion ✓, research ✓, evaluation ✓ (2 ready)
# Tick 2: execution ✓ (approved), broker ✓ (simulated fills)
# ...all visible in inspection dashboard
```
**Key benefits:**
- Deterministic test runs (same data every time)
- No API rate limits
- No need for live market hours
- Test configuration changes instantly
- Use in CI/CD for regression testing
---
### 🔲 Phase 8: Rich TUI (Optional)
**Estimated time:** ~3 hours
**What it does:** Terminal UI for SSH/CI/CD environments
**Files to create:**
```
tui/
├── dashboard.py # Rich-based TUI
└── components.py # Reusable widgets
```
**Features:**
- Progress bars for each module (ingestion, research, eval, exec, broker)
- Live metrics table (latencies, counts, error rates)
- Event log (colorized by severity)
- Keyboard commands (pause, resume, copy logs)
- Works over SSH (no browser needed)
**Usage:**
```bash
python run_live_paper.py --tui
```
**Output:**
```
[Ingestion] ████████░░░ 80% (400/500)
[Research] ██████████ 100% (5/5)
[Evaluation] ██████████ 100% (5/5 → 2 ready)
[Execution] ██████░░░░ 60% (1/2 approved)
[Broker] ████░░░░░░ 40% (2/5 submitted)
Recent Events:
[09:35:15] ✓ TSLA evaluated, ready (score 78%)
[09:35:14] ✗ AAPL blocked (rvol 1.2x < 1.5x)
[09:35:13] ✓ Ingestion complete (500 rows)
```
---
## Implementation Sequence
**Week 1:**
- Phase 1: Core infrastructure ✅ (DONE)
- Phase 2: Module instrumentation (4-6 hours)
**Week 2:**
- Phase 3: Inspection API (6 hours)
- Phase 4: React dashboard (8 hours)
**Week 3:**
- Phase 5: Module refactoring (6 hours)
- Phase 6: Test framework (5 hours)
**Week 4:**
- Phase 7: Dry-run mode (4 hours)
- Phase 8: TUI (3 hours, optional)
**Total:** ~4 weeks for full implementation, or ~2 weeks for core (phases 1-4).
---
## Why This Solves Your Problems
| Problem | Solution | Phase |
|---------|----------|-------|
| Can't see bars being collected | ModuleTickEvent shows input/output counts + validation | 1 + 2 |
| Can't verify research data | DataValidationEvent + SymbolEvaluationDetailEvent | 1 + 2 |
| Can't test modules independently | Module contracts + mock dependencies | 5 + 6 |
| UI doesn't help debug | Inspection dashboard with symbol inspector | 3 + 4 |
| No visibility into evaluation steps | SymbolEvaluationDetailEvent for each stage | 1 + 2 |
| Can't dry-run without APIs | --dry-run + synthetic bars | 7 |
---
## Next Immediate Steps
### Phase 2 Kickoff (Start Now)
1. **Identify all modules to instrument:**
- `research/ingestion/market_data.py` — `ingest_live_minute_bars()`
- `research/module.py` — `get_candidates()`, `get_bars()`
- `strategy/evaluation/setup_evaluator.py` — `evaluate_setup()`
- `trading_execution.py` — `TradingExecutionService.tick()`
- `alpaca_paper/sync.py` — `AlpacaPaperSync.sync_account()`
2. **For each module:**
- Wrap with `TelemetryCollector(module_name)`
- Record input/output counts
- Add validation with `emit_data_validation()`
- Emit `ModuleTickEvent` on completion
- Update error handling to record errors
3. **Update `run_live_paper.py`:**
- Initialize `TelemetryEmitter`
- Pass to modules (or use global instance)
- Test that existing behavior unchanged (should be!)
4. **Verify:**
- Run `python run_live_paper.py --once`
- Check event store has new events
- Verify metrics are accurate
---
## Success Criteria
After Phase 4 complete, you should be able to:
✅ Open http://127.0.0.1:8011 (inspection dashboard)
✅ See real-time module pipeline diagram
✅ Search for a symbol and see complete evaluation journey
✅ Understand exactly why a symbol is ready/blocked
✅ See timestamp, metrics, and raw data for each stage
✅ View live event stream
✅ Access data quality report
**Time to understand "why isn't AAPL signaling?":** < 10 seconds
---
## Files You Now Have
```
core/
├── __init__.py
├── contracts.py ← Module I/O types
├── telemetry.py ← Metric collection
└── validation.py ← Data validation
storage/
└── event_schema.py ← (extended) 3 new event types
Documentation/
├── PHASE1_IMPLEMENTATION.md ← How Phase 1 works
├── INSPECTION_ROADMAP.md ← Full system visibility
└── BUILD_SUMMARY.md ← This file
```
---
## Questions?
Each phase has clear, testable deliverables. After Phase 2 (instrumenting existing modules), you'll start seeing telemetry events in your event store.
After Phase 4 (React dashboard), you'll have full visibility into your system.
The foundation is solid. Ready to move to Phase 2?
+380
View File
@@ -0,0 +1,380 @@
# Data Transformations Flow
## Quick Reference: What Data Flows Where and How It Changes
### INGESTION LAYER
```
Alpaca REST API (live 1-min bars)
↓ ingest_live_minute_bars()
↓ transform: {symbol, timestamp, open, high, low, close, volume}
↓
research/market.duckdb → bars table
Alpaca REST API (daily bars history)
↓ ingest_daily_history()
↓ transform: {symbol, timestamp, open, high, low, close, volume}
↓
research/market.duckdb → daily_bars table
```
### DISCOVERY LAYER
```
Manual Symbols (--symbols or WATCHER_SYMBOLS env)
├─→ WatchCandidate(symbol, last_price, avg_volume)
│
Alpaca Most-Actives Screener (if --discover)
├─→ WatchCandidate(symbol, last_price, avg_volume)
│
ResearchWatchlistProvider.get_candidates(session_date)
↓ returns: List[WatchCandidate]
↓
Watcher ingests as new symbols to watch
```
### EVALUATION LAYER (CORE STRATEGY)
```
research/market.duckdb (minute bars for symbol)
↓ query_minute_bars(symbol, session_date)
↓ returns: pd.DataFrame with {timestamp, open, high, low, close, volume}
↓
evaluate_setup(bars, symbol)
├─ Extract metrics:
│ ├─ Gap% = (today_open - prev_close) / prev_close
│ ├─ RVOL = current_5min_volume / avg_20day_volume
│ ├─ Bull flag structure detection
│ ├─ Price action quality scoring
│ └─ 5 other criteria (tunable)
│
├─ Evaluate each criterion: pass/fail/not-evaluated
│
├─ Calculate score:
│ └─ criteria_passed / total_criteria * 100 = X%
│
└─ returns: {
score: float,
criteria: [
{name: "gap", passed: true, ...},
{name: "rvol", passed: false, ...},
...
],
entry: float,
stop: float,
target: float,
quality: float
}
```
### SIGNAL EMISSION
```
evaluate_setup() results
↓
├─ IF score ≥ ready_score_pct (60%) AND quality ≥ min_quality (30%):
│ └─ Watcher emits: SignalReadyEvent(
│ symbol, entry, stop, target, score, criteria, setup_data, ...
│ )
│ ✓ Recorded in event store
│ ✓ Marked as "already signaled" (debounced)
│
└─ ELSE:
└─ Watcher emits: SignalBlockedEvent(
symbol, reason, score, ...
)
✓ Resets signal debounce
```
### EXECUTION SIZING LAYER
```
SignalReadyEvent(entry, stop, target, ...)
↓
TradingExecutionService.tick()
├─ Check risk rules:
│ ├─ open_positions ≥ max_concurrent_positions? → REJECT
│ ├─ today_realized_pnl ≤ -max_daily_loss_pct * equity? → REJECT
│ └─ submitted_this_tick ≥ max_orders_per_tick? → SKIP
│
└─ calculate_position_size(entry, stop, equity, risk_pct)
│
├─ risk_dollars = equity * risk_pct (e.g., 1% = $1,000 on $100k)
├─ risk_per_share = entry - stop
├─ shares = risk_dollars / risk_per_share
└─ returns: {
shares: int,
entry: float,
stop: float,
target: float (entry + (entry-stop)*reward_multiple),
risk_dollars: float,
notional: float (entry * shares)
}
```
### APPROVAL LAYER
```
ExecutionRequest {shares, entry, stop, target, ...}
↓
TradingExecutionService emits: OrderApprovalRequestedEvent
├─ Stored in event store
├─ Projected into "approval_queue" projection
└─ Dashboard polls/streams this data
Dashboard receives approval request:
├─ Shows: symbol, entry, stop, target, shares, risk %, notional
├─ Shows: sparkline chart of last 50 bars with entry/stop references
├─ User clicks: Approve or Reject
│
├─ ON APPROVE:
│ └─ Dashboard emits: OrderApprovedEvent(order_id, ...)
│ ✓ Recorded in event store
│ ✓ TradingExecutionService detects and submits to broker
│
└─ ON REJECT:
└─ Dashboard emits: OrderRejectedEvent(order_id, reason, ...)
✓ Signal removed from queue
✓ Session reset for that symbol (can signal again)
```
### BROKER SUBMISSION LAYER
```
OrderApprovedEvent
↓
AlpacaPaperExecutor.submit()
├─ Create bracket order:
│ ├─ Entry: limit @ entry_price (can be cancelled/timeout)
│ ├─ Target: limit @ target_price
│ └─ Stop: stop @ stop_price (market when hit)
│
├─ Call Alpaca REST API: POST /orders (bracket order)
│ └─ returns: {id, symbol, qty, status, ...}
│
├─ Emit: OrderSubmittedEvent(broker_order_id, entry, stop, target, ...)
│ └─ Recorded in event store
│
└─ Track in _armed dict:
└─ _armed[order_id] = {
symbol, entry_price, broker_order_id,
armed_at, checks: {timeout, price_break}
}
Ongoing monitoring:
├─ Entry fills?
│ └─ Position opens (qty shares @ entry_price)
│
├─ Entry timeout? (after entry_timeout_bars minutes)
│ └─ Cancel unfilled entry order
│
├─ Price breaks back below entry - entry_invalidate_pct?
│ └─ Cancel unfilled entry order
│
└─ Target OR Stop hit?
└─ Position closes (filled at target or stop)
✓ Emit: OrderFilledEvent(symbol, entry, exit, qty, pnl, ...)
✓ Recorded in event store
```
### ACCOUNT SYNC LAYER
```
AlpacaPaperSync.sync_account()
│
├─ Alpaca REST GET /account
│ ↓ transform: {equity, buying_power, long_market_value, ...}
│ ↓ Emit: AccountSummaryUpdatedEvent(account_snapshot)
│
├─ Alpaca REST GET /positions
│ ↓ transform: [{symbol, qty, avg_fill, current_price, unrealized_pl}]
│ ↓ Emit: AccountPositionsUpdatedEvent(positions_list)
│
├─ Alpaca REST GET /orders?status=open
│ ↓ transform: [{id, symbol, qty, status, filled_qty, ...}]
│ ↓ Emit: AccountOrdersUpdatedEvent(orders_list)
│
└─ Calculate session P&L:
├─ Query event_store for all OrderFilledEvent in this session
├─ For each: realized_pnl = (exit_price - entry_price) * qty
├─ Aggregate: total_realized, total_unrealized, win_rate, avg_r
└─ Emit: SessionPnlUpdatedEvent(pnl_snapshot)
```
### PROJECTION LAYER (DASHBOARD READ-ONLY VIEWS)
```
EVENT STORE (canonical source)
├─ All events append-only, indexed by timestamp + session_id
│
└─ Projections (reconstructed views):
│
├─ approval_queue(event_store)
│ ├─ Filter: OrderApprovalRequestedEvent (status != approved/rejected)
│ └─ returns: [{order_id, symbol, entry, stop, target, shares, risk%}]
│
├─ account_positions_snapshot(event_store)
│ ├─ Latest: AccountPositionsUpdatedEvent
│ └─ returns: [{symbol, qty, entry, current, unrealized_pl}]
│
├─ pnl_snapshot(event_store)
│ ├─ Latest: SessionPnlUpdatedEvent
│ └─ returns: {realized, unrealized, total, win_rate, avg_r, open_count, closed_count}
│
├─ activity_feed(event_store, limit=100)
│ ├─ All events, chronological, most recent first
│ └─ returns: [{timestamp, event_type, message, symbol, ...}]
│
├─ ready_signals_snapshot(event_store)
│ ├─ Filter: SignalReadyEvent (not yet approved/rejected)
│ └─ returns: [{symbol, entry, stop, target, score, criteria}]
│
└─ symbol_criteria(event_store, symbol)
├─ Latest: CriteriaEvaluatedEvent for this symbol
└─ returns: [{name, passed, reason, value}] (9 criteria)
```
### DASHBOARD DELIVERY LAYER
```
Projections (from EVENT STORE)
│
├─ SSE Stream (/api/events)
│ ├─ WebSocket or Server-Sent Events
│ ├─ Pushes new events in real-time to all connected clients
│ └─ Format: {event_type, timestamp, symbol, ...}
│
└─ REST JSON Endpoints:
│
├─ GET /api/approval-queue
│ └─ returns: approval_queue projection (JSON)
│
├─ GET /api/positions
│ └─ returns: account_positions_snapshot projection (JSON)
│
├─ GET /api/pnl
│ └─ returns: pnl_snapshot projection (JSON)
│
├─ GET /api/activity-feed
│ └─ returns: activity_feed projection (JSON, last 100 events)
│
├─ GET /api/symbol/:symbol/criteria
│ └─ returns: 9 criteria with pass/fail/not-evaluated
│
├─ GET /api/symbol/:symbol/bars?minutes=50
│ └─ returns: [{timestamp, open, high, low, close, volume}]
│ (for sparkline chart rendering)
│
└─ POST /api/approval/:order_id/approve|reject
└─ Emits OrderApprovedEvent or OrderRejectedEvent
```
### FINAL OUTPUT (DASHBOARD UI)
```
Browser → http://127.0.0.1:8010
│
├─ P&L Strip (top)
│ └─ Realized | Unrealized | Total | Win Rate | Avg R | Open | Closed
│ (refreshed by /api/pnl endpoint)
│
├─ Approval Queue (cards)
│ ├─ Symbol | Entry | Stop | Target | Shares | Risk $ | Risk %
│ ├─ Sparkline chart (last 50 bars, entry/stop reference lines)
│ └─ [Approve] [Reject] buttons
│ (refreshed by /api/approval-queue endpoint or SSE)
│
├─ Open Positions
│ ├─ Symbol | Qty | Entry | Current | P&L | P&L% | Sparkline
│ └─ [Exit] button (closes position at market)
│ (refreshed by /api/positions endpoint)
│
├─ Activity Feed (chronological)
│ └─ {symbol} {event_type} {timestamp}
│ (refreshed by /api/activity-feed or SSE)
│
└─ Symbol Details (expand on click)
├─ Gap: ✓ Pass | 3.2%
├─ RVOL: ✗ Fail | 1.2 (need 1.5)
├─ Bull Flag: ? Not Evaluated
└─ ... (9 total criteria)
(fetched by /api/symbol/:symbol/criteria)
```
---
## Key Data Structure Flows
### Signal Ready → Order Submitted → Position Open → Fill
```
SignalReadyEvent(symbol, entry, stop, target, score, criteria)
↓ [user approves in dashboard]
↓ OrderApprovedEvent
↓ [execution service submits]
↓ OrderSubmittedEvent(broker_order_id, entry, stop, target, qty)
↓ [Alpaca processes bracket order]
↓ [entry order fills]
↓ OrderFilledEvent(entry_fill, qty)
↓ [position open, waiting for target or stop]
↓ [target or stop hit]
↓ OrderFilledEvent(exit_fill, qty, pnl)
```
### Symbol State Transitions with Events
```
Symbol Discovered (new symbol added)
↓ emit SymbolDiscoveredEvent
↓ state = "discovered"
↓
Symbol State Changed
↓ emit SymbolStateChangedEvent("discovered" → "watching")
↓ state = "watching"
↓
Criteria Evaluated
├─ IF score ≥ threshold:
│ ├─ emit SignalReadyEvent
│ ├─ emit SymbolStateChangedEvent("watching" → "ready")
│ ├─ state = "ready"
│ └─ signal_debounced = true (no more signal_ready until blocked)
│
└─ ELSE:
├─ emit SignalBlockedEvent
├─ emit SymbolStateChangedEvent("watching" → "blocked")
├─ state = "blocked"
└─ signal_debounced = false (can signal again if recovers)
```
### Position Lifecycle with P&L Calculation
```
Position Opens (OrderFilledEvent: entry)
├─ entry_price, qty, entry_time
├─ state = "open"
└─ emit AccountPositionsUpdatedEvent({symbol, qty, entry, current, unrealized_pl})
Position Ongoing
├─ AccountSummaryUpdatedEvent polls current price
├─ unrealized_pl = (current_price - entry_price) * qty
└─ Dashboard shows live sparkline + unrealized P&L
Position Closes (OrderFilledEvent: exit)
├─ exit_price, qty, exit_time
├─ realized_pnl = (exit_price - entry_price) * qty
├─ state = "closed"
├─ emit OrderFilledEvent({symbol, entry, exit, qty, pnl, pnl%, exit_reason})
└─ Dashboard adds to closed trades, updates session P&L
Session P&L Aggregation
├─ sum all closed_trades.realized_pnl
├─ sum all open_positions.unrealized_pl
├─ win_count = trades where pnl > 0
├─ loss_count = trades where pnl ≤ 0
├─ win_rate = win_count / (win_count + loss_count)
├─ avg_r = average(pnl / risk_per_trade)
└─ emit SessionPnlUpdatedEvent({realized, unrealized, total, win_rate, avg_r, counts})
```
---
## Summary: The Loop
Every **tick** (60 seconds):
1. **Ingest** → Market data into research DB
2. **Discover** → Get watchlist symbols
3. **Evaluate** → Run strategy engine on each symbol
4. **Signal** → Emit signal_ready or signal_blocked
5. **Execute** → Check approval queue, submit approved orders
6. **Sync** → Poll Alpaca for account/positions/orders
7. **Project** → Rebuild all read-only views from event store
8. **Deliver** → Push updates to dashboard (SSE or polling)
All transformations are **event-based** and **append-only**. The event store is the single source of truth. The dashboard is a **pure projection** of that store, so any session can be replayed perfectly and audited.
+377
View File
@@ -0,0 +1,377 @@
# Inspection & Debugging Roadmap
## What You'll Be Able to See (When All Phases Complete)
Right now, when you run `python run_live_paper.py`, you see:
- ❌ No indication bars are being collected
- ❌ No visibility into research data
- ❌ Symbols appear/disappear silently
- ❌ No way to test individual modules
- ❌ Can't verify calculation accuracy
**After Phases 1-4, you'll have:**
---
## 1. Real-Time Module Dashboard (Port 8011)
Open `http://127.0.0.1:8011` to see live module execution:
### Data Pipeline Diagram (Auto-Generated)
```
┌──────────────┐ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐
│ INGESTION │───→│ RESEARCH │───→│ EVALUATION │───→│ EXECUTION │
├──────────────┤ ├──────────────┤ ├────────────────┤ ├───────────────┤
│ 500 rows in │ │ 5 researched │ │ 5 evaluated │ │ 2 approved │
│ 245ms │ │ 156ms │ │ 1823ms │ │ 89ms │
│ ✓ validated │ │ ✓ all found │ │ 2 ready, 3 blocked │ 0 rejected │
└──────────────┘ └──────────────┘ └────────────────┘ └───────────────┘
```
Each box shows:
- **Input count** → "500 rows in"
- **Latency** → "245ms"
- **Status** → "✓ validated"
- **Click to expand** → see detailed metrics
### Module Performance Grid
```
┌───────────┬──────────────┬──────────┬────────────────┬─────────────────┐
│ Module │ Last Run (s) │ Avg (ms) │ Error Rate │ Input → Output │
├───────────┼──────────────┼──────────┼────────────────┼─────────────────┤
│ Ingestion │ 2.3s ago │ 245 │ 0.0% │ 500 → 500 │
│ Research │ 2.1s ago │ 156 │ 0.0% │ 5 → 5 │
│ Evaluation│ 0.8s ago │ 1823 │ 0.0% │ 5 → 2 │
│ Execution │ 0.5s ago │ 89 │ 0.0% │ 2 → 2 │
│ Broker │ 0.3s ago │ 342 │ 0.0% │ 2 → 2 │
└───────────┴──────────────┴──────────┴────────────────┴─────────────────┘
```
Click on each row to:
- See last 10 executions (histogram of latencies)
- View error log if failed
- Inspect metrics
---
## 2. Symbol Inspector (Search Box)
Type a symbol name to see its complete evaluation journey:
```
🔍 Search symbol... [AAPL]
────────────────────────────────────────────────────────────────
AAPL (state: blocked, score: 48%)
├─ INGESTION ✓
│ ├─ bars collected: 12
│ ├─ validation: passed ✓
│ ├─ quality_score: 0.82
│ └─ collected_at: 09:35:12
│
├─ RESEARCH ✓
│ ├─ gaps: 2.1%
│ ├─ rvol: 1.2x ✗ (need 1.5x)
│ ├─ daily avg volume: 500k
│ ├─ previous close: $10.50
│ └─ researched_at: 09:35:13
│
├─ EVALUATION ✓ bars_loaded
│ ├─ gap_checked ✓ (2.1%)
│ ├─ rvol_calculated ✗ (1.2x < 1.5x) ← BLOCKING REASON
│ ├─ bull_flag ✓ (confidence 0.75)
│ ├─ 1st_candle ✗ (weak)
│ ├─ momentum ✓
│ ├─ quality: 0.62
│ ├─ score: 48% (threshold: 60%)
│ └─ evaluated_at: 09:35:15
│
├─ EXECUTION (blocked - not evaluated)
│ └─ blocked_reason: "criteria score 48% < 60%"
│
└─ BROKER (N/A - not submitted)
```
Click on each section to see:
- Exact metric values
- Why it passed/failed
- Timestamp of calculation
- Raw data (for debugging)
### Sparkline Chart (OHLCV)
```
AAPL: 09:30 → 09:35 (12 bars)
11.00│ ╭─╮
10.80│ ╭─╮ ╭─╮╭─╯ ╰─╮
10.60│ ╭─╮ ╭─╮ ╭─╯╰─╮
10.40│╭─╯ ╰─╮
10.20│╯
└────────────────────────────
Volume: avg=125k, last=85k
Entry: $10.52 ━━━━━━━━━━━━━━
Stop: $10.20 ━━━━━━━━━━━━━━
```
Interactive:
- Hover over bars to see OHLCV
- Click to zoom in/out
- See entry/stop/target reference lines
---
## 3. Data Quality Report
```
📊 DATA QUALITY SNAPSHOT
────────────────────────────────────────────────────────────────
Ingestion Validation
├─ Rows validated: 500 ✓
├─ Schema failures: 0 ✓
├─ Checksum failures: 0 ✓
├─ Business rules failures: 0 ✓
└─ Warnings: 2 (zero-volume bars)
Research Data
├─ Market data freshness: 23 seconds old ✓
├─ Daily history freshness: 1 day old ✓
├─ Symbols missing data: 0 ✓
└─ DB integrity check: PASSED ✓
Evaluation Quality
├─ Criteria evaluated: 5 of 5 ✓
├─ Blocking reasons captured: 3 ✓
├─ Score distribution: [48%, 52%, 58%, 64%] (avg 55%)
└─ Scores < 60%: 3 symbols
Broker Connectivity
├─ API health: healthy ✓
├─ Last account sync: 2s ago ✓
├─ Last order: 45s ago ✓
└─ Latency: 342ms avg ✓
```
---
## 4. Event Timeline (Live Stream)
```
╔════════════════════════════════════════════════════════════════╗
║ EVENT TIMELINE (newest first, filter by module/symbol/type) ║
╚════════════════════════════════════════════════════════════════╝
09:35:15.347 [evaluation] ✓ completed duration 1823ms, 5 evaluated, 2 ready
09:35:14.512 [evaluation] AAPL: rvol_calculated (1.2x < 1.5x) ✗
09:35:14.381 [evaluation] AAPL: gap_checked (2.1%) ✓
09:35:13.856 [evaluation] TSLA: bull_flag_detected (conf 0.85) ✓
09:35:13.245 [data_validation] ingestion: schema validation PASSED (500 rows)
09:35:13.122 [research] ✓ completed duration 156ms, 5 researched
09:35:12.856 [ingestion] ✓ completed duration 245ms, 500 rows inserted
09:35:12.123 [ingestion] ↦ started (source: alpaca, 500 bars)
09:35:10.234 [execution] AAPL: blocked (score 48% < 60%)
09:35:09.856 [approval_queue] → empty (all processed)
09:35:09.453 [broker] ✓ positions synced 3 open positions
09:35:09.123 [broker] ✓ account updated equity $99,234
```
Click on any event to see:
- Full event JSON
- Correlation ID (trace all events for one signal)
- Copy to clipboard
---
## 5. Dry-Run Mode (Phase 7)
```bash
# Test with synthetic bull-flag setups (no API calls, no broker)
$ python run_live_paper.py --dry-run \
--symbols AAPL,TSLA,AMD \
--bars-provider synthetic:bull_flag \
--num-ticks 5 \
--inspection-dashboard
Starting dry-run mode (no API calls, no broker)
Using synthetic bull-flag bars: 50 bars/symbol
Inspection dashboard: http://127.0.0.1:8011
Tick 1 (09:30-09:35)
Ingestion: ✓ 150 bars collected
Research: ✓ 3 symbols researched
Evaluation: ✓ 3 evaluated → 2 ready (AAPL, TSLA)
Execution: ✓ 2 approved
Broker: ✓ 2 orders submitted (simulated)
Tick 2 (09:35-09:40)
Ingestion: ✓ 150 bars collected
Research: ✓ 3 symbols researched
Evaluation: ✓ 3 evaluated → 1 ready (AMD)
Execution: ✓ 1 approved
Broker: ✓ 1 fill (AAPL +$250), 2 pending
... (Dashboard shows all metrics live)
```
---
## 6. Module-Level Testing (Phase 6)
```bash
# Test just the evaluation module with deterministic data
$ pytest tests/modules/test_evaluation_module.py -v
tests/modules/test_evaluation_module.py::test_evaluation_ready ✓
- Bull-flag bars pass evaluation
- Score: 85%
- Entry: $150.50
- Stop: $149.00
tests/modules/test_evaluation_module.py::test_evaluation_blocked ✓
- Fading bars fail evaluation
- Score: 35%
- Blocking: low volume
- Reason: RVOL 0.8x < 1.5x
tests/modules/test_evaluation_module.py::test_evaluation_dry_run ✓
- Can run without research DB
- Works with injected bars
- Full metrics returned
tests/modules/test_evaluation_module.py::test_evaluation_validation ✓
- Invalid bars rejected
- Validation failures captured
- Error messages clear
tests/modules/test_evaluation_module.py::test_evaluation_contract ✓
- Accepts EvaluationRequest
- Returns EvaluationResult
- All fields populated
5 passed in 0.23s
```
---
## 7. Rich TUI Mode (Phase 8, Optional)
```bash
# Real-time terminal UI (useful for SSH, CI/CD logs)
$ python run_live_paper.py --tui
╔═══════════════════════════════════════════════════════════════╗
║ MOMENTUM TRADING SYSTEM - TUI DASHBOARD ║
╚═══════════════════════════════════════════════════════════════╝
[Ingestion] ████████████░░░░░░░░░░░░ 50% (250/500 bars)
[Research] ████████████████████░░░░ 85% (4/5 symbols)
[Evaluation] ████████████████████░░░░ 85% (4/5 evaluated)
[Execution] ████████████░░░░░░░░░░░░ 50% (1/2 approved)
[Broker] ███░░░░░░░░░░░░░░░░░░░░░ 15% (1/5 submitted)
┌─ Module Latencies ──────────────────────────────┐
│ Ingestion: 245ms │
│ Research: 156ms │
│ Evaluation: 1823ms ← slow (5 symbols) │
│ Execution: 89ms │
│ Broker: 342ms │
└─────────────────────────────────────────────────┘
┌─ Recent Events ─────────────────────────────────┐
│ [09:35:15] evaluation ✓ TSLA ready │
│ [09:35:14] evaluation ✗ AAPL blocked (rvol) │
│ [09:35:13] research ✓ 5 symbols researched │
│ [09:35:12] ingestion ✓ 500 bars collected │
└─────────────────────────────────────────────────┘
[press 'q' to quit, 's' to toggle details, 'c' to copy logs]
```
---
## Phase Completion Checklist
| Phase | What Gets Built | When You Can Use It |
|-------|-----------------|-------------------|
| ✅ 1 | Core contracts + telemetry | After Phase 1 |
| 📍 2 | Module instrumentation | After Phase 2 |
| 🔲 3 | Inspection API endpoints | After Phase 3 |
| 🔲 4 | Web dashboard frontend | After Phase 4 |
| 🔲 5 | Module refactoring | After Phase 5 |
| 🔲 6 | Module tests + fixtures | After Phase 6 |
| 🔲 7 | `--dry-run` mode + synthetic data | After Phase 7 |
| 🔲 8 | Rich TUI (optional) | After Phase 8 |
---
## Usage Scenarios
### "I want to debug why AAPL isn't signaling"
**With inspection dashboard:**
1. Open http://127.0.0.1:8011
2. Search "AAPL" in symbol inspector
3. Expand each stage to see:
- Bars collected: 12 ✓
- Gap: 2.1% ✓
- RVOL: 1.2x ✗ (blocked here!)
4. Click "RVOL" stage to see:
- Current volume: 85k
- 20-day average: 500k
- Ratio: 0.17x (need 1.5x)
5. Understand exactly why it's blocked
**Time saved:** 10 seconds vs. 30 minutes digging through logs
---
### "I want to test the evaluation module alone"
**With Phase 6:**
```bash
pytest tests/modules/test_evaluation_module.py -v
```
- No database needed
- No API calls
- Deterministic test data (bull flags, fading bars)
- See exactly what each criterion does
- Change thresholds and re-test instantly
---
### "I want to see if my strategy works with dry-run data"
**With Phase 7:**
```bash
python run_live_paper.py --dry-run \
--bars-provider synthetic:bull_flag \
--num-ticks 5
```
- No API keys needed
- No broker interaction
- Dashboard still works
- See signals, approval, execution all flow through
- Test configuration changes risk-free
---
## Summary
**Before Phases 1-4:** Black box ("did it work?")
**After Phases 1-4:** Transparent pipeline ("here's exactly what happened at each step, why it passed/failed, and how long it took")
This is the foundation for:
- ✅ Debugging blocked signals in seconds
- ✅ Testing modules independently
- ✅ Verifying data accuracy at each stage
- ✅ Dry-running strategies without real APIs
- ✅ Understanding your system's behavior
Next: **Phase 2** instruments the existing modules. Then Phases 3-4 build the visual dashboard to make all this data human-readable.
+503
View File
@@ -0,0 +1,503 @@
# Phase 1: Module Contracts & Telemetry Infrastructure
## What Was Built
Phase 1 establishes the **foundation** for modular, observable trading system. Instead of black-box components, every module now has:
1. **Explicit I/O contracts** — standardized input/output types
2. **Telemetry collection** — metrics, timing, error tracking
3. **Data validation** — schema checks, business rule validation, checksums
4. **Event types** — new event schema for module observability
This makes the system **transparent**: you can see exactly what data is flowing where, how long each step takes, and what validation passed/failed.
---
## New Modules
### `core/contracts.py` — Module I/O Contracts
Defines TypeScript-like contracts for every module:
```python
# Every module accepts typed input and produces typed output
BarIngestRequest ResearchRequest EvaluationRequest
↓ ↓ ↓
BarIngestResult ResearchResult EvaluationResult
```
**Key classes:**
- `ModuleMetrics` — Standard metrics every module tracks
- `duration_ms`: How long the module took
- `input_count`, `output_count`: Items processed
- `error_count`, `warnings`: Quality metrics
- `custom_metrics`: Module-specific (gap%, rvol, etc.)
- `ModuleResult` — Base class for all module outputs
- `success`: Did it work?
- `validation_failures`: What went wrong?
- `error_summary()`: Human-readable error message
- **Five module contracts** (ingestion, research, evaluation, execution, broker)
- Each has `XyzRequest` (input) and `XyzResult` (output)
- Strongly typed, IDE-friendly
- Makes contracts explicit
**Usage:**
```python
from core.contracts import EvaluationRequest, EvaluationResult
# Module accepts strongly-typed input
request = EvaluationRequest(
symbol="AAPL",
bars_df=my_dataframe,
previous_close=150.0,
avg_daily_volume=1_000_000,
session_date=date(2026, 6, 15)
)
# Module produces strongly-typed output
result: EvaluationResult = evaluate_setup(request)
# Safe to access fields — IDE autocomplete works
if result.status == "ready":
print(f"Entry: ${result.entry_price}")
for criterion in result.criteria:
print(f" {criterion.name}: {'✓' if criterion.passed else '✗'}")
```
---
### `core/telemetry.py` — Metric Collection & Emission
Real-time telemetry collection and event emission.
**Key classes:**
- `TelemetryCollector` — Collect metrics during module execution
```python
collector = TelemetryCollector("evaluation")
# Record what you're processing
collector.record_input(count=5) # evaluated 5 symbols
# Record custom metrics
collector.record_metric("gap_pct", 2.5)
collector.record_metric("rvol", 1.8)
# Finalize when done
metrics = collector.finalize()
# → ModuleMetrics with duration_ms, counts, custom metrics
```
- `TelemetryEmitter` — Emit events to event store
```python
emitter = get_telemetry_emitter()
# Emit module execution telemetry
emitter.emit_module_tick(
module_name="evaluation",
stage="completed",
metrics=metrics,
correlation_id="session-123"
)
# Emit data validation results
emitter.emit_data_validation(
module_name="ingestion",
validation_type="schema",
valid=True,
details={
"rows_validated": 500,
"failures": []
}
)
# Emit detailed symbol evaluation metrics
emitter.emit_symbol_evaluation_detail(
symbol="AAPL",
stage="gap_checked",
metrics={"gap_pct": 2.5, "threshold": 2.0},
passed=True
)
```
- **Callbacks** — Emit events to multiple destinations
```python
emitter = get_telemetry_emitter()
# Add callback to receive all telemetry events
emitter.add_callback(lambda event: logger.info(f"Event: {event['type']}"))
# When you emit, all callbacks are called
emitter.emit_module_tick(...)
```
---
### `core/validation.py` — Data Validation
Multi-layer validation at module boundaries.
**Validators:**
1. **SchemaValidator** — Check required/optional fields and types
```python
validator = SchemaValidator(
required_fields={"symbol": str, "qty": int, "price": float},
optional_fields={"notes": str}
)
is_valid, failures = validator.validate({"symbol": "AAPL", "qty": 100, "price": 150.0})
# failures = [] (if valid)
# or failures = [{"field": "qty", "reason": "Expected int, got str", ...}]
```
2. **RangeValidator** — Numeric bounds checking
```python
rvol_validator = RangeValidator(min_val=1.0, max_val=10.0)
is_valid, failure = rvol_validator.validate(1.5, field_name="rvol")
```
3. **ChecksumValidator** — Data integrity verification
```python
data = {"bars": [...]100 items...}
checksum = ChecksumValidator.compute_checksum(data)
# checksum = "a1b2c3d4e5f6..." (MD5)
# Later, verify data hasn't changed
is_valid, failure = ChecksumValidator.validate_checksum(data, checksum)
```
4. **BusinessRuleValidator** — Custom logic validation
```python
validator = BusinessRuleValidator()
# Add custom rules
validator.add_rule(
"min_high_price",
lambda data: (data["high"] > data["low"], "high must be > low")
)
is_valid, failures = validator.validate(bar_data)
```
5. **BarDataValidator** — Pre-built OHLCV validation
```python
is_valid, failures = BarDataValidator.validate_bar({
"timestamp": "2026-06-15 09:31:00",
"open": 150.0,
"high": 151.0,
"low": 149.5,
"close": 150.5,
"volume": 50000
})
# Checks:
# - All required fields present
# - High ≥ open, close, low
# - Low ≤ open, close, high
# - Volume non-negative
# - Warns on zero volume
```
---
## New Event Types
Added to `storage/event_schema.py`:
### 1. `ModuleTickEvent` — Module Execution Telemetry
Emitted by each module at the end of each tick.
```python
@dataclass
class ModuleTickEvent(BaseEvent):
module: str # "ingestion", "research", "evaluation", etc.
stage: str # "started", "processing", "completed", "failed"
duration_ms: float # How long it took
input_count: int # Items processed
output_count: int # Items produced
metrics: dict # Custom module metrics
errors: list[dict] # Any errors that occurred
```
**Example:**
```json
{
"type": "module_tick",
"module": "evaluation",
"stage": "completed",
"duration_ms": 245.5,
"input_count": 5,
"output_count": 2,
"metrics": {
"avg_score": 58.3,
"ready_count": 2,
"blocked_count": 3
},
"errors": [],
"timestamp": "2026-06-15T09:35:00"
}
```
### 2. `DataValidationEvent` — Data Quality Checkpoint
Emitted when module validates data at boundaries.
```python
@dataclass
class DataValidationEvent(BaseEvent):
module: str # Module that validated
validation_type: str # "schema", "checksum", "business_rules"
valid: bool # Did it pass?
details: dict # Validation specifics
```
**Example:**
```json
{
"type": "data_validation",
"module": "ingestion",
"validation_type": "schema",
"valid": true,
"details": {
"rows_validated": 500,
"rows_failed": 0,
"failures": []
}
}
```
### 3. `SymbolEvaluationDetailEvent` — Deep-Dive Symbol Metrics
Emitted during symbol evaluation to show intermediate calculations.
```python
@dataclass
class SymbolEvaluationDetailEvent(BaseEvent):
symbol: str # Symbol being evaluated
stage: str # "bars_loaded", "gap_checked", "rvol_calculated", etc.
metrics: dict # Metrics at this stage
passed: bool # Did this stage pass?
blocking_reason: str # Why it failed (if applicable)
```
**Example:**
```json
{
"type": "symbol_evaluation_detail",
"symbol": "AAPL",
"stage": "gap_checked",
"metrics": {
"gap_pct": 2.5,
"threshold": 2.0
},
"passed": true,
"blocking_reason": null,
"timestamp": "2026-06-15T09:35:12"
}
```
```json
{
"type": "symbol_evaluation_detail",
"symbol": "AAPL",
"stage": "rvol_calculated",
"metrics": {
"rvol": 1.2,
"threshold": 1.5,
"current_5min_volume": 125000,
"avg_20day_volume": 1000000
},
"passed": false,
"blocking_reason": "RVOL 1.2x below threshold 1.5x",
"timestamp": "2026-06-15T09:35:14"
}
```
---
## Usage Pattern: Instrumenting an Existing Module
Here's how to add telemetry to any module (e.g., evaluation):
```python
from core.telemetry import TelemetryCollector, emit_module_tick, emit_symbol_evaluation_detail
from core.validation import BarDataValidator
def evaluate_setup(bars_df, symbol, previous_close, avg_daily_volume):
"""Evaluate setup with full telemetry and validation."""
collector = TelemetryCollector("evaluation")
try:
# Validate input
bars = bars_df.to_dict('records')
for i, bar in enumerate(bars):
is_valid, failures = BarDataValidator.validate_bar(bar)
if not is_valid:
collector.record_error()
emit_symbol_evaluation_detail(
symbol=symbol,
stage="bars_validated",
metrics={"valid_bars": i, "total_bars": len(bars)},
passed=False,
blocking_reason=f"Bar {i} invalid: {failures[0]['reason']}"
)
return EvaluationResult(success=False, metrics=collector.finalize())
collector.record_input(len(bars))
# Emit validation checkpoint
emit_data_validation(
module_name="evaluation",
validation_type="schema",
valid=True,
details={"rows_validated": len(bars), "failures": []}
)
# Stage 1: Gap check
gap_pct = ((bars[-1]['open'] - previous_close) / previous_close) * 100
emit_symbol_evaluation_detail(
symbol=symbol,
stage="gap_checked",
metrics={"gap_pct": gap_pct, "threshold": 2.0},
passed=gap_pct >= 2.0
)
collector.record_metric("gap_pct", gap_pct)
# Stage 2: RVOL check
current_5min_vol = bars[-1]['volume']
rvol = current_5min_vol / avg_daily_volume
rvol_passed = rvol >= 1.5
emit_symbol_evaluation_detail(
symbol=symbol,
stage="rvol_calculated",
metrics={
"rvol": rvol,
"threshold": 1.5,
"current_5min_vol": current_5min_vol,
"avg_20day_vol": avg_daily_volume
},
passed=rvol_passed,
blocking_reason=None if rvol_passed else f"RVOL {rvol:.2f}x below 1.5x"
)
collector.record_metric("rvol", rvol)
# ... more stages ...
# Finalize
collector.record_output(1)
metrics = collector.finalize()
# Emit module completion
emit_module_tick(
module_name="evaluation",
stage="completed",
metrics=metrics
)
return EvaluationResult(
success=True,
metrics=metrics,
symbol=symbol,
status="ready",
score=score
)
except Exception as e:
collector.record_error()
metrics = collector.finalize()
emit_module_tick(
module_name="evaluation",
stage="failed",
metrics=metrics,
errors=[{"message": str(e)}]
)
return EvaluationResult(success=False, metrics=metrics)
```
---
## Why This Matters
### Before (Black Box)
```
Bars ingested → [silence] → Signal ready
❓ Were bars actually valid?
❓ Which criterion failed?
❓ How long did it take?
❓ What was the gap%?
```
### After (Transparent)
```
ModuleTickEvent:
module: "ingestion"
input_count: 500
output_count: 500
metrics: {
"rows_inserted": 500,
"validation_failures": 0
}
DataValidationEvent:
module: "ingestion"
validation_type: "schema"
valid: true
SymbolEvaluationDetailEvent (AAPL):
stage: "gap_checked"
metrics: {"gap_pct": 2.5}
passed: true
SymbolEvaluationDetailEvent (AAPL):
stage: "rvol_calculated"
metrics: {"rvol": 1.2}
passed: false
blocking_reason: "RVOL 1.2x below 1.5x"
```
---
## Next Steps
**Phase 2** will instrument the existing modules (ingestion, research, evaluation, execution, broker) to emit these events.
**Phase 3** will build the Inspection Dashboard API to query and visualize these events.
**Phase 4** will build the React frontend to make the data human-readable.
**Phase 7** will add `--dry-run` mode so you can test with synthetic data.
---
## Files Created
```
core/
├── __init__.py # Empty init
├── contracts.py # Module I/O contracts (5 modules)
├── telemetry.py # Metric collection & emission
└── validation.py # Multi-layer validation
storage/
└── event_schema.py # (MODIFIED) Added 3 new event types
```
---
## Key Takeaway
The system is no longer a black box. Every module now:
1. ✅ Has **explicit contracts** (input/output types)
2. ✅ **Emits telemetry** (duration, counts, metrics)
3. ✅ **Validates data** at module boundaries
4. ✅ **Reports errors** with context
This foundation enables the inspection dashboard, dry-run mode, and per-module testing to all work correctly.
+487
View File
@@ -0,0 +1,487 @@
# Phase 2 Preview: What You'll Be Coding
This preview shows **exactly** what Phase 2 implementation looks like, so you can see the work upfront.
---
## Example 1: Instrumenting `ingest_live_minute_bars()`
### Before (Phase 1)
```python
def ingest_live_minute_bars(alpaca_client, research_db, symbols):
"""Ingest latest 1-minute bars from Alpaca."""
for symbol in symbols:
bars = alpaca_client.get_bars(symbol, lookback_minutes=120)
# Insert into DB silently...
research_db.insert_bars(symbol, bars)
# No telemetry, no validation, silent success/failure
```
### After (Phase 2)
```python
from core.telemetry import TelemetryCollector, emit_module_tick, emit_data_validation
from core.validation import BarDataValidator
def ingest_live_minute_bars(alpaca_client, research_db, symbols):
"""Ingest latest 1-minute bars from Alpaca with full telemetry."""
collector = TelemetryCollector("ingestion")
validation_failures = []
try:
for symbol in symbols:
try:
bars = alpaca_client.get_bars(symbol, lookback_minutes=120)
collector.record_input(len(bars))
# Validate each bar before insertion
for i, bar in enumerate(bars):
is_valid, failures = BarDataValidator.validate_bar(bar)
if not is_valid:
collector.record_error()
validation_failures.extend(failures)
# Insert valid bars
if not validation_failures:
research_db.insert_bars(symbol, bars)
collector.record_output(len(bars))
collector.record_metric(f"{symbol}_volume_avg",
sum(b['volume'] for b in bars) / len(bars))
except Exception as e:
collector.record_error()
collector.record_warning(f"{symbol} failed: {e}")
# Emit validation checkpoint
emit_data_validation(
module_name="ingestion",
validation_type="schema",
valid=len([f for f in validation_failures if f['severity'] == 'error']) == 0,
details={
"rows_validated": collector.input_count,
"validation_failures": validation_failures,
"symbols_processed": len(symbols)
}
)
# Emit completion telemetry
metrics = collector.finalize()
emit_module_tick(
module_name="ingestion",
stage="completed",
metrics=metrics
)
except Exception as e:
collector.record_error()
metrics = collector.finalize()
emit_module_tick(
module_name="ingestion",
stage="failed",
metrics=metrics,
errors=[{"message": str(e)}]
)
raise
```
**What changed:**
- ✅ Wrapped with `TelemetryCollector`
- ✅ Validate bars before insertion
- ✅ Record input/output counts
- ✅ Track custom metrics (volume averages)
- ✅ Emit validation + completion events
- ✅ Error handling with telemetry
**You'll see in event store:**
```json
{
"type": "module_tick",
"module": "ingestion",
"stage": "completed",
"duration_ms": 245,
"input_count": 500,
"output_count": 500,
"metrics": {
"AAPL_volume_avg": 125000,
"TSLA_volume_avg": 98000,
"AMD_volume_avg": 75000
},
"errors": []
}
```
---
## Example 2: Instrumenting `evaluate_setup()`
### Before (Phase 1)
```python
def evaluate_setup(bars, symbol, previous_close, avg_daily_volume):
"""Evaluate setup criteria."""
gap_pct = ((bars[-1]['open'] - previous_close) / previous_close) * 100
rvol = bars[-1]['volume'] / avg_daily_volume
# ... evaluate 7 more criteria silently ...
criteria_passed = sum([gap_pct >= 2.0, rvol >= 1.5, ...])
score = (criteria_passed / 9) * 100
return {
'status': 'ready' if score >= 60 else 'blocked',
'score': score,
# ... hidden metrics ...
}
# User never sees WHY it passed/failed
```
### After (Phase 2)
```python
from core.telemetry import (
TelemetryCollector,
emit_module_tick,
emit_symbol_evaluation_detail
)
from core.validation import RangeValidator
def evaluate_setup(bars, symbol, previous_close, avg_daily_volume):
"""Evaluate setup criteria with full telemetry."""
collector = TelemetryCollector("evaluation")
criteria_results = []
try:
collector.record_input(1) # 1 symbol evaluated
# STAGE 1: Gap check
gap_pct = ((bars[-1]['open'] - previous_close) / previous_close) * 100
gap_passed = gap_pct >= 2.0
emit_symbol_evaluation_detail(
symbol=symbol,
stage="gap_checked",
metrics={"gap_pct": gap_pct, "threshold": 2.0},
passed=gap_passed,
blocking_reason=None if gap_passed else f"Gap {gap_pct:.1f}% < 2.0%"
)
collector.record_metric("gap_pct", gap_pct)
criteria_results.append(("gap", gap_passed, gap_pct))
# STAGE 2: RVOL check
current_5min_vol = bars[-1]['volume']
rvol = current_5min_vol / avg_daily_volume
rvol_passed = rvol >= 1.5
emit_symbol_evaluation_detail(
symbol=symbol,
stage="rvol_calculated",
metrics={
"rvol": rvol,
"threshold": 1.5,
"current_5min_vol": current_5min_vol,
"avg_20day_vol": avg_daily_volume
},
passed=rvol_passed,
blocking_reason=None if rvol_passed else f"RVOL {rvol:.2f}x < 1.5x"
)
collector.record_metric("rvol", rvol)
criteria_results.append(("rvol", rvol_passed, rvol))
# STAGE 3: Bull flag detection
bull_flag_score, bull_flag_details = detect_bull_flag(bars)
bull_flag_passed = bull_flag_score >= 0.5
emit_symbol_evaluation_detail(
symbol=symbol,
stage="bull_flag_detected",
metrics={
"confidence": bull_flag_score,
"threshold": 0.5,
"details": bull_flag_details
},
passed=bull_flag_passed,
blocking_reason=None if bull_flag_passed else f"Bull flag confidence {bull_flag_score:.2f} < 0.5"
)
collector.record_metric("bull_flag_confidence", bull_flag_score)
criteria_results.append(("bull_flag", bull_flag_passed, bull_flag_score))
# ... evaluate 6 more criteria, each with emit_symbol_evaluation_detail ...
# Calculate final score
criteria_passed = sum(1 for _, passed, _ in criteria_results if passed)
total_criteria = len(criteria_results)
score = (criteria_passed / total_criteria) * 100
quality = bull_flag_score * (rvol / 1.5) # Custom quality metric
emit_symbol_evaluation_detail(
symbol=symbol,
stage="score_calculated",
metrics={
"score": score,
"criteria_passed": criteria_passed,
"total_criteria": total_criteria,
"quality": quality,
"threshold": 60
},
passed=score >= 60,
blocking_reason=None if score >= 60 else f"Score {score:.1f}% < 60%"
)
collector.record_metric("score", score)
collector.record_metric("quality", quality)
# Determine status
status = "ready" if score >= 60 else "blocked"
if status == "ready":
collector.record_output(1)
# Entry/stop/target calculation
entry_price = bars[-1]['close']
stop_price = bars.min()['low'] * 0.99
target_price = entry_price + (entry_price - stop_price) * 2.0
emit_symbol_evaluation_detail(
symbol=symbol,
stage="levels_calculated",
metrics={
"entry": entry_price,
"stop": stop_price,
"target": target_price,
"risk": entry_price - stop_price,
"reward": target_price - entry_price,
"reward_ratio": (target_price - entry_price) / (entry_price - stop_price)
},
passed=True
)
# Finalize telemetry
metrics = collector.finalize()
emit_module_tick(
module_name="evaluation",
stage="completed",
metrics=metrics
)
return {
'status': status,
'score': score,
'criteria': [
{'name': name, 'passed': passed, 'value': value}
for name, passed, value in criteria_results
],
'entry': entry_price,
'stop': stop_price,
'target': target_price,
'quality': quality
}
except Exception as e:
collector.record_error()
metrics = collector.finalize()
emit_module_tick(
module_name="evaluation",
stage="failed",
metrics=metrics,
errors=[{"symbol": symbol, "message": str(e)}]
)
raise
```
**What you'll see in event store:**
```json
[
{
"type": "symbol_evaluation_detail",
"symbol": "AAPL",
"stage": "gap_checked",
"metrics": {"gap_pct": 2.1, "threshold": 2.0},
"passed": true
},
{
"type": "symbol_evaluation_detail",
"symbol": "AAPL",
"stage": "rvol_calculated",
"metrics": {"rvol": 1.2, "threshold": 1.5, "current_5min_vol": 85000},
"passed": false,
"blocking_reason": "RVOL 1.20x < 1.5x"
},
{
"type": "symbol_evaluation_detail",
"symbol": "AAPL",
"stage": "bull_flag_detected",
"metrics": {"confidence": 0.75, "threshold": 0.5},
"passed": true
},
{
"type": "symbol_evaluation_detail",
"symbol": "AAPL",
"stage": "score_calculated",
"metrics": {"score": 48.0, "criteria_passed": 4, "total_criteria": 9, "threshold": 60},
"passed": false,
"blocking_reason": "Score 48.0% < 60%"
},
{
"type": "module_tick",
"module": "evaluation",
"stage": "completed",
"duration_ms": 1823,
"input_count": 5,
"output_count": 2,
"metrics": {
"avg_score": 58.3,
"ready_count": 2,
"blocked_count": 3
}
}
]
```
---
## Example 3: Updating `run_live_paper.py`
### Small changes needed
```python
from core.telemetry import TelemetryEmitter, set_telemetry_emitter
from storage.event_store import EventStore
def build_runtime(args: argparse.Namespace) -> dict:
"""Wire up every component; safe without API keys."""
# ... existing code ...
event_store = EventStore()
# NEW: Initialize telemetry emitter
emitter = TelemetryEmitter(event_store=event_store)
set_telemetry_emitter(emitter) # Make it global
# ... rest of existing code ...
return {
'event_store': event_store,
'emitter': emitter, # NEW
# ... other components ...
}
```
That's it! The modules will use the global emitter automatically.
---
## Example 4: Instrumenting `TradingExecutionService`
```python
def tick(self):
"""Execute one tick with telemetry."""
collector = TelemetryCollector("execution")
# Check for new signal_ready events
ready_signals = query_ready_signals_snapshot(self.store)
collector.record_input(len(ready_signals))
for signal in ready_signals:
try:
# Risk checks
open_pos = self._open_position_count()
if open_pos >= self.settings.max_concurrent_positions:
emit_symbol_evaluation_detail(
symbol=signal.symbol,
stage="risk_check_max_positions",
metrics={"open_positions": open_pos, "max": self.settings.max_concurrent_positions},
passed=False,
blocking_reason=f"Max positions {open_pos} >= limit {self.settings.max_concurrent_positions}"
)
continue
# Position sizing
shares = calculate_position_size(
entry=signal.entry,
stop=signal.stop,
equity=self.equity,
risk_pct=self.settings.risk_per_trade_pct
)
# Emit approval request
self.store.emit(OrderApprovalRequestedEvent(...))
collector.record_output(1)
except Exception as e:
collector.record_error()
metrics = collector.finalize()
emit_module_tick(
module_name="execution",
stage="completed",
metrics=metrics
)
```
---
## Time Breakdown for Phase 2
| Task | Time |
|------|------|
| Instrument ingestion module | 30 mins |
| Instrument research module | 30 mins |
| Instrument evaluation module | 60 mins (most complex) |
| Instrument execution module | 45 mins |
| Instrument broker sync module | 30 mins |
| Update run_live_paper.py | 15 mins |
| Test & verify | 45 mins |
| **Total** | **~4 hours** |
---
## Verification Checklist for Phase 2
After completing Phase 2, you should be able to:
```bash
# Run the system
python run_live_paper.py --once
# Check event store has new events
sqlite3 data/momentum_events.duckdb "SELECT COUNT(*), event_type FROM events GROUP BY event_type;"
# Expected output:
# 5 | module_tick
# 3 | data_validation
# 15 | symbol_evaluation_detail
# ... (plus existing event types)
# View a symbol's complete journey
sqlite3 data/momentum_events.duckdb \
"SELECT event_type, symbol, stage, passed FROM events \
WHERE symbol='AAPL' AND event_type IN ('symbol_evaluation_detail', 'data_validation') \
ORDER BY timestamp;"
# Expected output:
# symbol_evaluation_detail | AAPL | gap_checked | 1
# symbol_evaluation_detail | AAPL | rvol_calculated | 0
# symbol_evaluation_detail | AAPL | bull_flag_detected | 1
# ...
```
---
## Key Points
1. **Minimal changes to existing code** — mostly wrapping with telemetry
2. **No breaking changes** — all existing functionality preserved
3. **Backward compatible** — code still works without telemetry
4. **Testable** — each instrumented module still works alone
5. **Progressive** — instrument one module at a time, test, then move to next
---
## What Phase 3 Needs
Once Phase 2 is done, Phase 3 will:
- Query the `symbol_evaluation_detail` events for symbol inspector
- Query `module_tick` events for module performance grid
- Query `data_validation` events for quality report
Everything needed will be in the event store already!
+268
View File
@@ -0,0 +1,268 @@
================================================================================
MOMENTUM SYSTEM - QUICK REFERENCE
================================================================================
PHASE 1 (✅ COMPLETE): Core Infrastructure
────────────────────────────────────────────────────────────────────────────
What was built:
✓ core/contracts.py Module I/O contracts (5 modules × 2 types each)
✓ core/telemetry.py Metric collection + event emission
✓ core/validation.py Data validation (schema, ranges, checksums, rules)
✓ 3 new event types (ModuleTickEvent, DataValidationEvent, SymbolEvaluationDetailEvent)
Why it matters:
BEFORE: ingest_live_minute_bars() → [silence] → "did it work?"
AFTER: "500 bars collected, 0 validation failures, completed in 245ms"
Key classes:
TelemetryCollector Record what's happening during execution
TelemetryEmitter Emit events to event store + callbacks
ModuleMetrics duration_ms, input_count, output_count, custom_metrics
SchemaValidator Check required fields and types
BarDataValidator Pre-built OHLCV validation
Quick import:
from core.telemetry import TelemetryCollector, emit_module_tick
from core.validation import SchemaValidator, BarDataValidator
Quick usage:
collector = TelemetryCollector("evaluation")
collector.record_input(5)
collector.record_metric("gap_pct", 2.5)
metrics = collector.finalize()
emit_module_tick("evaluation", "completed", metrics)
================================================================================
PHASE 2 (📍 NEXT): Module Instrumentation (~4 hours)
────────────────────────────────────────────────────────────────────────────
What you'll do:
1. Wrap ingest_live_minute_bars() with TelemetryCollector
2. Wrap evaluate_setup() with telemetry + SymbolEvaluationDetailEvent
3. Wrap research, execution, broker modules
4. Update run_live_paper.py to initialize emitter
5. Test and verify events appear in event store
Each module will emit:
✓ ModuleTickEvent at completion (duration, counts, metrics)
✓ DataValidationEvent when data validated
✓ SymbolEvaluationDetailEvent for each evaluation stage (evaluation module only)
See: PHASE2_PREVIEW.md for exact code examples
================================================================================
PHASE 3 (🔲 TODO): Inspection Dashboard API (~6 hours)
────────────────────────────────────────────────────────────────────────────
Build: api/inspection.py with REST endpoints
Endpoints:
GET /api/inspection/modules module metrics table
GET /api/inspection/pipeline pipeline flow diagram data
GET /api/inspection/symbol/:symbol eval timeline for symbol
GET /api/inspection/data-quality validation report
GET /api/inspection/events SSE stream of events
GET /api/inspection/module/:m/history last N executions
Projections to build:
query_module_performance()
query_symbol_evaluation_detail(symbol)
query_data_quality_snapshot()
query_pipeline_stats(session_id)
================================================================================
PHASE 4 (🔲 TODO): React Dashboard (~8 hours)
────────────────────────────────────────────────────────────────────────────
Build: api/inspection/ directory with React SPA
Features:
□ Pipeline diagram (Mermaid auto-layout)
□ Module performance grid (5x5 table with metrics)
□ Symbol inspector (search + expandable stages)
□ Sparkline charts (OHLCV with entry/stop lines)
□ Event timeline (live feed with filtering)
□ Data quality report (heatmap + validation stats)
□ Live SSE updates + polling fallback
□ Mobile responsive
Port: http://127.0.0.1:8011 (separate from execution dashboard :8010)
================================================================================
PHASES 5-8 (OPTIONAL): Refactoring & Testing
────────────────────────────────────────────────────────────────────────────
Phase 5: Module refactoring into explicit contracts
modules/ingestion.py, research.py, evaluation.py, execution.py, broker.py
Phase 6: Module-level testing
tests/modules/test_*.py (one per module)
Fixtures for synthetic data (bull_flag_bars, fading_bars, etc.)
Phase 7: Dry-run mode + synthetic data
python run_live_paper.py --dry-run --bars-provider synthetic:bull_flag
Phase 8: Rich TUI (terminal UI)
python run_live_paper.py --tui
(useful for SSH, CI/CD, no browser needed)
================================================================================
CURRENT FILES CREATED
────────────────────────────────────────────────────────────────────────────
Core:
core/__init__.py
core/contracts.py 5 module contracts
core/telemetry.py Metric collection + emission
core/validation.py Multi-layer validation
Extended:
storage/event_schema.py +3 event types
Documentation:
README_ARCHITECTURE_REDESIGN.md Start here! Big picture
BUILD_SUMMARY.md All 8 phases + timeline
PHASE1_IMPLEMENTATION.md How Phase 1 works
PHASE2_PREVIEW.md Exact Phase 2 code examples
INSPECTION_ROADMAP.md What you'll see after Phase 4
QUICK_REFERENCE.txt This file
Old (no longer needed):
ARCHITECTURE_VISUAL.html Old static visualization
SYSTEM_FLOW.txt Old text flow diagram
DATA_TRANSFORMATIONS.md Old transformation doc
================================================================================
TIMELINE
────────────────────────────────────────────────────────────────────────────
Week 1:
Phase 1: Core infrastructure ✅ DONE (~2 hours)
Phase 2: Module instrumentation 📍 START (~4 hours)
Week 2:
Phase 3: Inspection API backend (~6 hours)
Phase 4: React dashboard frontend (~8 hours)
Week 3:
Phase 5: Module refactoring (~6 hours)
Phase 6: Testing framework (~5 hours)
Week 4:
Phase 7: Dry-run mode (~4 hours)
Phase 8: TUI (optional) (~3 hours)
Total: ~4 weeks for full implementation, or ~2 weeks for core (1-4)
================================================================================
SUCCESS METRICS
────────────────────────────────────────────────────────────────────────────
After Phase 4, you'll be able to:
✓ Open http://127.0.0.1:8011 (inspection dashboard)
✓ See real-time module pipeline (ingestion → research → eval → exec → broker)
✓ Search for a symbol → see complete evaluation journey
✓ Understand why AAPL is blocked (exact stage, metric, threshold)
✓ View timestamps, metrics, and raw data for each stage
✓ See live event stream
✓ Access data quality report
Time to answer "why isn't AAPL signaling?" → 10 seconds (was 30 minutes)
================================================================================
KEY INSIGHTS
────────────────────────────────────────────────────────────────────────────
The System Used to Work Like:
Alpaca API → [silence] → Signal ready → [silence] → Order submitted
❓ Were bars validated?
❓ Which criterion failed?
❓ How long did evaluation take?
❓ What was the gap%?
The System Will Work Like:
Alpaca API → ModuleTickEvent(500 rows in, 245ms) → Signal ready
→ SymbolEvaluationDetailEvent(gap_checked: 2.1% ✓)
→ SymbolEvaluationDetailEvent(rvol_calculated: 1.2x ✗)
→ SymbolEvaluationDetailEvent(score: 48% < 60%)
→ Order rejected (criteria not met)
Dashboard shows all of this in real-time
================================================================================
NEXT IMMEDIATE STEPS
────────────────────────────────────────────────────────────────────────────
1. Read README_ARCHITECTURE_REDESIGN.md (10 minutes)
2. Read BUILD_SUMMARY.md (20 minutes)
3. Review PHASE1_IMPLEMENTATION.md (15 minutes)
4. Skim PHASE2_PREVIEW.md for code examples (10 minutes)
5. Decide: Start Phase 2 or want clarifications?
If starting Phase 2:
Use PHASE2_PREVIEW.md as your code template
Follow task list in BUILD_SUMMARY.md
Test after each module (verify events in DB)
Expected time: ~4 hours
================================================================================
QUESTIONS?
────────────────────────────────────────────────────────────────────────────
Common questions answered in:
"How do I use TelemetryCollector?"
→ PHASE1_IMPLEMENTATION.md, "Usage Pattern" section
"What does Phase 2 code look like?"
→ PHASE2_PREVIEW.md, Examples 1-4
"What will the dashboard show me?"
→ INSPECTION_ROADMAP.md, "Real-Time Module Dashboard" section
"Can I test modules independently?"
→ Phase 6, coming soon (BUILD_SUMMARY.md)
"How do I run without API keys?"
→ Phase 7, --dry-run mode (BUILD_SUMMARY.md)
"Where do I look if ingestion fails?"
→ Inspection dashboard, Module Grid, click "ingestion", see error
"How do I know bars are being collected?"
→ ModuleTickEvent shows input_count after Phase 2
"How do I verify research data?"
→ DataValidationEvent after Phase 2, dashboard after Phase 4
================================================================================
READY TO START?
────────────────────────────────────────────────────────────────────────────
Phase 1 is complete. You have the foundation.
Phase 2 is ~4 hours of work:
- Wrap 5 modules with TelemetryCollector
- Add validation checkpoints
- Emit completion events
- Test
After Phase 2, you'll start seeing telemetry events in your event store.
After Phase 4, you'll have a fully functional inspection dashboard.
Let's go!
================================================================================
+116
View File
@@ -0,0 +1,116 @@
# Momentum
Event-sourced small-cap momentum day-trading system (Ross Cameron–style):
gap-and-go / bull-flag setups on 1-minute bars, with paper execution on
Alpaca and a live-broker integration for Schwab.
> **Important rebuild note.** This codebase was reconstructed from scratch
> against the original architecture and interfaces after the original source
> files became unrecoverable in the working environment. Diff it against your
> local copy before discarding anything — module layout and public interfaces
> were preserved, but implementations are fresh. See `RUNBOOK.md` for what
> changed and why.
## What this system does
```
Alpaca IEX bars ──► research/market.duckdb ──► Watcher ──► signal_ready
(1-min, live) (minute + daily) │
▼
Dashboard (approve/reject) ◄── approval queue ◄── TradingExecutionService
│ │
▼ ▼
exit orders ─────────────────────────► Alpaca paper account
│
AlpacaPaperSync ◄─────┘
(account/positions/orders → event store → dashboard)
```
Everything observable flows through a single append-only **event store**
(`data/momentum.duckdb`): symbol discovery, criteria scores, signals,
approval requests, orders, fills, account snapshots, broker health. The
dashboard and all read paths are pure projections over those events, so any
session can be replayed and audited.
## Milestones
| # | Scope | Status |
|---|-------|--------|
| M1 | Strategy logic — setup evaluation, structure detection, quality, backtest | ✅ `strategy/` |
| M2 | Event store + projections | ✅ `storage/` |
| M3 | Schwab integration — OAuth, market data, positions/orders, health | ✅ `schwab/` (`python milestone3_verify.py`) |
| M4 | Watcher + live paper execution | ✅ `runtime/`, `alpaca_paper/`, `trading_execution.py`, `run_live_paper.py` |
| M5 | Dashboard UI + API | ✅ `api/`, `dashboard_api.py` |
## Quick start (paper trading)
```bash
pip install -r requirements.txt
cp .env.example .env # fill in ALPACA_API_KEY / ALPACA_SECRET_KEY
python run_live_paper.py # full loop + dashboard at http://127.0.0.1:8010
```
Signals appear in the dashboard's **Approval Queue**; click **Approve** to
send the bracket order to your Alpaca paper account (or start with
`--auto-approve` / `TRADING_AUTO_APPROVE=1`). Use the **Exit** button on a
position to close it at market.
No keys? `python run_live_paper.py --once` still runs in dry mode so you can
verify wiring.
### Dashboard (control room)
The dashboard is a single self-contained page (no build step) that streams
live state over Server-Sent Events and falls back to polling if the stream
drops. It shows:
- **P&L strip** — realized, unrealized, total, win rate, average R, and
open/closed trade counts for the session.
- **Live sparkline charts** on every ready signal and open position, drawn
from 1-minute bars with dashed entry (blue) and stop (red) reference lines.
- **Pre-trade risk preview** in the approval queue — dollar risk, share count,
notional, and stop distance before you approve.
- **Click-to-expand criteria** on any watched symbol — all nine setup criteria
as pass / fail / not-evaluated, so a "blocked" symbol is legible at a glance.
- **Activity feed** of submissions and fills, plus the full order lifecycle.
- **Audio + toast alerts** when a new signal goes ready (toggle the sound
button, or press `S`).
- **Manual symbol injection** — type a ticker (or press `/`) to force it onto
the watchlist mid-session.
- **Keyboard shortcuts** — `A` approve / `R` reject the top of the queue,
`S` toggle sound, `/` focus the add-symbol box, `?` help.
## Repository map
```
strategy/ pure strategy engine: evaluation, structure, backtest
storage/ event schema, event store, DuckDB schema, projections
runtime/ watcher state machine + provider registry
research/ research DBs, ingestion (bars, RSS, gappers), providers
alpaca_paper/ Alpaca paper client, executor, account sync
schwab/ Schwab OAuth, market data, positions/orders, health
trading_execution.py signal → sizing → approval → order pipeline
run_live_paper.py the orchestrator (M4 end-to-end)
api/ dashboard JSON API + static UI
dashboard_api.py standalone read-only dashboard server
tests/ unit + integration suite (pytest)
```
CLI utilities: `fetch_minute_bars.py` (ad-hoc bar ingestion),
`backtest_cli.py` (intraday backtests over stored sessions),
`research_cli.py` (session symbols / gapper scan / RSS news),
`milestone3_verify.py` (Schwab health walk-through).
## Tests
```bash
python -m pytest # 60 tests: strategy, storage, watcher, execution,
# schwab, api, research, end-to-end pipeline
```
## Security
`data/schwab_tokens.json` (and all of `data/`) is git-ignored and written
with `0600` permissions. **If tokens were ever committed to your repository
history, revoke and re-issue them at Schwab** — see RUNBOOK.md.
+311
View File
@@ -0,0 +1,311 @@
# Architecture Redesign: From Black Box to Transparent Pipeline
## The Problem You Identified
- ❌ Can't see if bars are being collected
- ❌ Can't verify research data is accurate
- ❌ Can't test modules independently
- ❌ UI provides no debugging help
- ❌ No visibility into calculation steps
---
## The Solution: 8-Phase Implementation
A complete redesign that transforms the system from a black box into a transparent, observable, testable pipeline.
### Overview
```
Phase 1: Core Infrastructure ✅ DONE
↓
Phase 2: Module Instrumentation (4 hours)
↓
Phase 3: Inspection API Backend (6 hours)
↓
Phase 4: React Dashboard (8 hours)
↓
Phases 5-8: Refactoring & Optional Features
```
---
## Documentation
Start with these files in order:
### 1. **READ FIRST: BUILD_SUMMARY.md**
- Executive summary of all 8 phases
- Timeline and effort estimates
- Success criteria
- Why this solves your problems
### 2. **PHASE1_IMPLEMENTATION.md**
- What was built in Phase 1 (contracts, telemetry, validation)
- How to use each new component
- Example code patterns
- Key takeaway: System is no longer a black box
### 3. **INSPECTION_ROADMAP.md**
- What you'll be able to see after all phases complete
- Interactive dashboard features
- Symbol inspector details
- Real-world debugging scenarios
### 4. **PHASE2_PREVIEW.md**
- Exactly what Phase 2 code looks like
- Before/after comparisons
- How many events you'll see
- Time breakdown for Phase 2 work
---
## Quick Start: What's Ready Now (Phase 1)
### New Modules
**`core/contracts.py`** — Module I/O Contracts
```python
# Every module has typed input/output
EvaluationRequest → evaluate_setup() → EvaluationResult
```
**`core/telemetry.py`** — Metric Collection
```python
# Record what's happening during execution
collector = TelemetryCollector("evaluation")
collector.record_metric("gap_pct", 2.5)
collector.record_output(2) # 2 symbols ready
metrics = collector.finalize()
```
**`core/validation.py`** — Data Validation
```python
# Validate data at module boundaries
validator = SchemaValidator(required_fields={"symbol": str, "price": float})
is_valid, failures = validator.validate(data)
```
### New Event Types
In `storage/event_schema.py`:
- `ModuleTickEvent` — Module execution telemetry
- `DataValidationEvent` — Data quality checkpoints
- `SymbolEvaluationDetailEvent` — Deep-dive symbol metrics
### Example Usage
```python
from core.telemetry import emit_module_tick, emit_symbol_evaluation_detail
# Emit when symbol evaluation stage completes
emit_symbol_evaluation_detail(
symbol="AAPL",
stage="rvol_calculated",
metrics={"rvol": 1.2, "threshold": 1.5},
passed=False,
blocking_reason="RVOL 1.2x < 1.5x threshold"
)
# Events are now in the event store, queryable by the dashboard
```
---
## Next: Phase 2 (Ready to Start)
Phase 2 instruments the existing modules to emit telemetry events.
**Work:** ~4 hours
1. Wrap `ingest_live_minute_bars()` with telemetry
2. Wrap `evaluate_setup()` with telemetry
3. Wrap execution, broker sync, research modules
4. Test to verify events are being emitted
**See:** PHASE2_PREVIEW.md for exact code examples
---
## The End Goal: Full Transparency
After all 8 phases, opening `http://127.0.0.1:8011` shows:
```
AAPL (state: blocked, score: 48%)
├─ INGESTION ✓
│ └─ 12 bars collected, validation passed
├─ RESEARCH ✓
│ ├─ gap: 2.1% ✓
│ └─ rvol: 1.2x ✗ (need 1.5x) ← BLOCKING
├─ EVALUATION
│ ├─ gap_checked ✓
│ ├─ rvol_calculated ✗ ← stops here
│ └─ score: 48% (threshold: 60%)
└─ (not evaluated, blocked upstream)
```
**Time to understand why AAPL didn't signal:** < 10 seconds
---
## Files Overview
### Core Infrastructure (Phase 1)
```
core/
├── contracts.py # Module I/O types (500 lines)
├── telemetry.py # Metric collection (250 lines)
└── validation.py # Data validation (400 lines)
```
### Documentation
```
PHASE1_IMPLEMENTATION.md # How Phase 1 works (200 lines)
INSPECTION_ROADMAP.md # Full system visibility (400 lines)
BUILD_SUMMARY.md # All 8 phases + timeline (350 lines)
PHASE2_PREVIEW.md # Exact Phase 2 code (300 lines)
```
### Later Phases
```
api/inspection.py # Phase 3: Inspection API
api/inspection/ # Phase 4: React dashboard
modules/ # Phase 5: Refactored modules
tests/modules/ # Phase 6: Module tests
```
---
## Key Design Principles
1. **Explicit Contracts**
- Every module has `Request` → `Result` types
- IDE autocomplete friendly
- Bugs obvious when types don't match
2. **Observability First**
- Every module emits telemetry
- Metrics, timing, errors all captured
- Events flow to event store
3. **Validation at Boundaries**
- Data validated when entering each module
- Business rules checked
- Checksums computed
4. **Modularity**
- Each component is independent
- Can test in isolation
- Can swap implementations (mock vs. real)
5. **Transparency**
- Nothing happens silently
- All state changes are events
- Complete audit trail
---
## Success Metrics
| Metric | Before | After |
|--------|--------|-------|
| Time to debug "why isn't AAPL signaling?" | 30 mins | 10 secs |
| Can test evaluation module alone | No | Yes |
| Can see bars being collected | No | Yes |
| Can verify research data accuracy | No | Yes |
| Module error tracking | None | Complete |
| Data validation coverage | None | 100% |
---
## Implementation Path
### Immediate (This Week)
- ✅ Phase 1: Core infrastructure (DONE)
- 📍 Phase 2: Instrument existing modules (4 hours)
### Week 2
- Phase 3: Inspection API (6 hours)
- Phase 4: React dashboard (8 hours)
### Week 3+
- Phases 5-8: Refactoring, testing, optional features
---
## Questions to Ask While Reading
As you go through the documentation, ask yourself:
1. **On contracts:** "If I call `evaluate_setup()` with an `EvaluationRequest`, what exactly should I expect back?"
→ Answer: An `EvaluationResult` with specific fields
2. **On telemetry:** "How long did evaluation take? How many symbols were ready? What metrics does it track?"
→ Answer: All captured in `ModuleTickEvent`
3. **On validation:** "What if a bar has zero volume? What if the high is lower than the low?"
→ Answer: `BarDataValidator` catches both
4. **On events:** "If I want to understand why AAPL is blocked, where do I look?"
→ Answer: `SymbolEvaluationDetailEvent` shows each step
5. **On inspection:** "Can I see the exact gap%, rvol, and bull-flag score?"
→ Answer: Yes, all in the dashboard after Phase 4
---
## Reading Order
1. **BUILD_SUMMARY.md** — Start here for the big picture
2. **PHASE1_IMPLEMENTATION.md** — Understand what was built
3. **PHASE2_PREVIEW.md** — See what you'll code next
4. **INSPECTION_ROADMAP.md** — Visualize the end goal
5. **Code files** — `core/contracts.py`, `core/telemetry.py`, `core/validation.py`
---
## Next Steps
1. Read BUILD_SUMMARY.md (20 minutes)
2. Review PHASE1_IMPLEMENTATION.md (15 minutes)
3. Skim PHASE2_PREVIEW.md to see code examples (10 minutes)
4. Decide: Ready to start Phase 2, or want clarifications first?
If ready for Phase 2:
- Use PHASE2_PREVIEW.md as your code template
- Follow the work breakdown in BUILD_SUMMARY.md
- Test after each module is instrumented
- Verify events appear in event store
---
## Summary
**Problem:** Black-box system, no visibility, can't test modules, can't debug
**Solution:** 8-phase implementation adding:
- ✅ Module contracts (Phase 1)
- 📍 Module telemetry (Phase 2)
- Module inspection API (Phase 3)
- Module inspection dashboard (Phase 4)
- Module refactoring (Phase 5)
- Module testing (Phase 6)
- Dry-run mode (Phase 7)
- TUI debugging (Phase 8)
**Timeline:** 4 weeks for full implementation, 2 weeks for core (phases 1-4)
**Benefit:** From 30 minutes to 10 seconds to understand why a signal didn't execute
---
## Let's Go
Phase 1 is complete. Phase 2 is ready to start.
Which would help you more right now:
1. More explanation of the design?
2. Code walkthrough of Phase 1?
3. Start working on Phase 2?
Let me know!
+227
View File
@@ -0,0 +1,227 @@
# RUNBOOK
Operational guide for running the momentum system. This file replaces the
original MILESTONE*.md documents, which were unrecoverable during the
rebuild.
---
## 1. Why live paper trading never worked (root causes, now fixed)
1. **Minute bars were never ingested.** The watcher reads 1-minute bars from
`data/research/market.duckdb`, but the old ingestion only wrote *daily*
bars for a handful of mega-caps. Fix: `research/ingestion/market_data.py`
ingests 1-minute Alpaca IEX bars continuously, and
`run_live_paper.py` schedules it every `LIVE_BARS_INTERVAL_SECONDS`.
2. **Session flags were hardcoded.** Every old row had
`is_regular_hours = TRUE`. Fix: flags are derived from the bar timestamp
in US/Eastern (premarket 04:00–09:30, regular 09:30–16:00, afterhours
16:00–20:00) — see `classify_session()` and its tests.
3. **The candidate filter excluded everything ingested.** The query had a
hardcoded `close BETWEEN 1 AND 20` while only mega-caps (price ≫ 20) were
in the database, so the watchlist was always empty. Fix: the band is
configurable (`WATCHER_PRICE_MIN` / `WATCHER_PRICE_MAX`) and the default
symbol list now actually gets ingested.
4. **No orchestrator.** Ingestion, watcher, broker sync, and execution were
never wired into one loop. Fix: `run_live_paper.py`.
5. **Broken source files.** `runtime/providers/registry.py`,
`schwab/market/models.py`, `option_chain_service.py`, and
`schwab/streaming/client.py` had syntax errors that crashed imports.
All repaired/rewritten.
6. **Schwab tokens were committed to git.** `data/schwab_tokens.json` is now
git-ignored and written `chmod 0600`. **Action required:** if that file
exists anywhere in your repo history, revoke the refresh token in the
Schwab developer portal and re-run the OAuth flow. Scrub history with
`git filter-repo --path data/schwab_tokens.json --invert-paths` if the
repo was ever shared.
---
## 2. Environment setup
```bash
pip install -r requirements.txt
cp .env.example .env
```
Minimum for paper trading: `ALPACA_API_KEY`, `ALPACA_SECRET_KEY`
(free keys are fine — the system uses the IEX feed). Everything else has a
sensible default; see `.env.example` for the full catalog.
Key knobs:
| Variable | Default | Meaning |
|---|---|---|
| `TRADING_EXECUTION_MODE` | `alpaca_paper` | `schwab_live` routes to real money — leave on paper |
| `TRADING_AUTO_APPROVE` | `0` | `1` = orders submit without dashboard approval (or pass `--auto-approve`) |
| `TRADING_MAX_ORDERS_PER_TICK` | `1` | throttle new entries |
| `TRADING_MAX_CONCURRENT_POSITIONS` | `3` | cap on simultaneous open positions |
| `TRADING_RISK_PER_TRADE_PCT` | `0.01` | 1% of equity risked per trade (sizing = risk / (entry − stop)) |
| `TRADING_REWARD_MULTIPLE` | `2.0` | profit target = entry + this × risk (2R by default) |
| `TRADING_ENTRY_ORDER_TYPE` | `limit` | `limit` rests at the trigger so an unfilled entry can be cancelled; `market` fills instantly |
| `TRADING_ENTRY_TIMEOUT_BARS` | `2` | cancel an unfilled entry after this many minutes resting (wall-clock; `0` disables the time box) |
| `TRADING_ENTRY_INVALIDATE_PCT` | `0.0` | cancel an unfilled entry if the **live last-trade price** trades this fraction below the trigger (`0.0` = any break below entry; `0.005` = 0.5% below; negative disables) |
| `TRADING_ENTRY_GUARD_INTERVAL_SECONDS` | `5` | how often the fast guard re-checks armed entries against the live price — lower = faster break detection, more quote calls |
| `TRADING_MAX_DAILY_LOSS_PCT` | `0.03` | daily-loss circuit breaker |
| `WATCHER_SYMBOLS` | `AAPL,TSLA,…` | static watchlist; `--discover` adds screener actives |
| `WATCHER_PRICE_MIN/MAX` | `1` / `20` | candidate price band |
| `WATCHER_EVENT_DB_PATH` | `./data/momentum.duckdb` | the event store |
| `DASHBOARD_HOST/PORT` | `127.0.0.1` / `8010` | UI address |
---
## 3. Start commands
**Full live paper loop (recommended):**
```bash
python run_live_paper.py
# dashboard: http://127.0.0.1:8010
# flags: --once --symbols SNDL,COSM --auto-approve --discover --no-dashboard
```
Each tick prints one status line:
```
[09:31:02] ingest: 240 minute rows | watch: evaluated=5 ready=['SNDL'] blocked=3 | sync: ok | execute: approvals_requested=['…'] auto_executed=0
```
**Standalone read-only dashboard** (e.g. to review a past session):
```bash
python dashboard_api.py
```
Approve/reject/exit buttons require the orchestrator (it attaches the
execution service to the same server); the standalone runner returns 503 on
actions by design.
**Other tools:**
```bash
python fetch_minute_bars.py SNDL COSM --lookback 390 # ad-hoc ingestion
python research_cli.py symbols --date 2026-06-11 # what's in the DB
python research_cli.py gappers --date 2026-06-11 # gap scan → scanner_snapshots
python backtest_cli.py --date 2026-06-10 # backtest a stored session
python milestone3_verify.py # Schwab health walk
python -m pytest # full test suite
```
---
## 4. The trading day, end to end
1. **Ingest** — every 60 s, 1-minute IEX bars for the watchlist land in
`data/research/market.duckdb` with correct session flags. On boot, a
30-day daily backfill gives gap % and relative volume a baseline.
2. **Watch** — every 30 s the watcher pulls in-band candidates, evaluates
nine weighted criteria (gap, RVOL, impulse, pullback, pullback volume,
VWAP, candle quality, breakout, data sufficiency) plus structure
detection, and drives `discovered → watching → ready | blocked | late`.
A `signal_ready` is emitted at most once per symbol per session.
3. **Sync** — Alpaca account, positions, and orders snapshot into the event
store so projections and risk guards see broker truth.
4. **Execute** — ready signals become orders sized at `risk_per_trade_pct`
of equity, with the stop from the setup and the target at
entry + `reward_multiple` × risk (2R by default). Manual mode parks them
in the dashboard queue; **auto mode (`--auto-approve`) submits
immediately, no approval.** Guards: max concurrent positions, no re-entry
into held symbols, one request per symbol per session.
5. **Manage** — approve/reject from the dashboard; **Exit** sends a market
sell for the synced quantity.
### The entry lifecycle (auto-arm → fill or back out)
The earlier system would "see" a setup as something that had *already
happened* rather than a trade about to happen, and never pull the trigger.
The execution model now separates recognition from conviction from
discipline:
1. **Recognize** — when all the criteria pass, a `signal_ready` fires. The
setup is evaluated against the **last bar's timestamp**, not the wall
clock, so a live 9:40 breakout is judged against the 9:40 cutoff instead
of being dismissed as hours stale.
2. **Arm with conviction** — in auto mode the order is submitted right away
as a **bracket** (entry + stop + target placed atomically). With
`TRADING_ENTRY_ORDER_TYPE=limit` (default) the entry rests at the trigger,
so "unfilled" is a real, observable state.
3. **Back out at a defined point** — a fast **guard loop**
(`TRADING_ENTRY_GUARD_INTERVAL_SECONDS`, default 5 s) re-checks every
armed-but-unfilled entry and cancels it if either:
- the **live last-trade price** (pulled tick-by-tick from Alpaca's data
API, not a bar close) trades back below the trigger by
`TRADING_ENTRY_INVALIDATE_PCT`, or
- it has rested longer than `TRADING_ENTRY_TIMEOUT_BARS` minutes
(wall-clock — running the guard often does **not** shorten this window).
A cancel frees the risk budget and the concurrent-position slot, and emits
a `risk_rule_triggered` (`entry_backout`) you can see in the activity feed.
Once the order **fills**, it stops being tracked and the bracket's stop and
target manage the open position — no second-guessing.
Tuning cheat-sheet: react to a fakeout faster → lower
`TRADING_ENTRY_GUARD_INTERVAL_SECONDS` (e.g. `2`); more patience on the fill →
raise `TRADING_ENTRY_TIMEOUT_BARS`; give the breakout room to wobble → set
`TRADING_ENTRY_INVALIDATE_PCT` to something like `0.005`; chase fills instead
of resting → `TRADING_ENTRY_ORDER_TYPE=market` (note: market fills can't time
out); bigger winners → raise `TRADING_REWARD_MULTIPLE`.
> **Quote rate limits.** The guard makes one latest-trade call per armed
> symbol per pass (a ~2 s cache collapses duplicates). At the 5 s default with
> a few open entries you're well inside Alpaca's free-tier 200 req/min. If you
> drop the interval to 1 s with several armed entries, watch the budget —
> bars ingestion and account sync also draw from it.
### Reading the dashboard
- **Badge** — blue `PAPER · ALPACA` vs red `LIVE · SCHWAB`. If it's red and
you didn't mean it, stop and check `TRADING_EXECUTION_MODE`.
- **P&L strip** (top) — session realized / unrealized / total, win rate,
average R, open/closed counts. Green/red signed.
- **Approval Queue** — pending entries with a **risk preview** ($ risk,
shares, notional, stop %) and Approve/Reject. Keyboard: `A` / `R` act on the
top row.
- **Ready Signals / Open Positions** — each row has a live **sparkline** (last
~40 one-minute closes) with dashed entry (blue) and stop (red) lines.
- **Watch States** — click any row to expand the **nine-criteria breakdown**
(✓ pass / ✕ fail / · not-evaluated). The add-symbol box (or `/`) injects a
ticker onto the watchlist for the rest of the session.
- **Activity Feed** — submissions and fills as they happen.
- **Order Lifecycle** — per-order status from the event store.
- **Connection dot** (top right) — green pulse = live SSE stream; it falls
back to 4 s polling automatically if the stream drops.
- **Sound** — toggle the button or press `S` for an audio + toast alert when a
new signal goes ready. Press `?` for the shortcut list.
Streams from `GET /api/stream` (SSE); also exposes `/api/snapshots`,
`/api/criteria?symbol=`, `/api/bars?symbol=&minutes=`, and
`POST /api/watch/add`. Charts require the research DB, which the orchestrator
wires in automatically; the standalone `dashboard_api.py` has no bars source,
so sparklines there show "no bars".
---
## 5. Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| `watch: evaluated=0` all day | No minute bars for today. Check keys; run `python research_cli.py symbols`. Outside market hours there are no new bars — backfill with `fetch_minute_bars.py --lookback 1440`. |
| Candidates exist but never `ready` | Score < 60%. Watch the `Watch States` panel score column; criteria details are in `criteria_evaluated` events. |
| `ingest: … 403` errors | Free keys are IEX-only — ensure `ALPACA_DATA_FEED=iex` (default) and that the request window ends ≥30 s in the past (handled automatically). |
| Approve button does nothing | You're on `dashboard_api.py` (read-only). Use `run_live_paper.py`. |
| Port 8010 busy | Set `DASHBOARD_PORT`, or `--no-dashboard`. |
| Schwab shows `SCHWAB-UNAUTH` | Expected without OAuth. Run the flow in `schwab/auth/oauth2_flow_manager.py`; verify with `milestone3_verify.py`. |
| Start fresh | Delete `data/momentum.duckdb` (events) and/or `data/research/` (bars). Both rebuild automatically. |
---
## 6. Going live on Schwab (when ready)
Paper-trade until the stats earn it. Then: complete OAuth
(`milestone3_verify.py` all green), set `TRADING_EXECUTION_MODE=schwab_live`,
keep `TRADING_AUTO_APPROVE=0`, and start with the smallest viable
`TRADING_RISK_PER_TRADE_PCT`. The dashboard badge turns red in live mode.
+405
View File
@@ -0,0 +1,405 @@
================================================================================
MOMENTUM TRADING SYSTEM - COMPLETE LOGICAL FLOW
================================================================================
ENTRY POINT: python run_live_paper.py [--once] [--symbols AAPL,SNDL] [--auto-approve]
================================================================================
STARTUP PHASE
================================================================================
1. INITIALIZE CONFIGURATION
├─ Load .env (ALPACA_API_KEY, ALPACA_SECRET_KEY, etc.)
├─ Parse CLI args
└─ Create settings objects: AlpacaPaperSettings, ExecutionSettings, TradingModeSettings
2. INITIALIZE STORAGE & DATABASES
├─ EventStore("data/momentum_events.duckdb")
│ └─ Append-only log of all events (canonical source of truth)
├─ ResearchConnection("data/research/market.duckdb")
│ └─ Minute bars + daily bars for all symbols
└─ AlpacaPaperClient (if API keys present)
└─ Connection to Alpaca paper trading account
3. INITIALIZE RUNTIME COMPONENTS
├─ Watcher (state machine for symbol discovery/evaluation)
│ └─ Provider: ResearchWatchlistProvider (pulls symbols from research DB)
├─ TradingExecutionService (signal → approval → order pipeline)
├─ AlpacaPaperSync (poll Alpaca account/positions/orders)
└─ DashboardAPI (optional HTTP server on port 8010)
└─ Serves JSON API + static UI + SSE event stream
================================================================================
MAIN LOOP (runs every tick, or once with --once)
================================================================================
TICK = 1 iteration (typically 60 seconds)
┌─────────────────────────────────────────────────────────────────────────┐
│ PHASE 1: INGEST LATEST MARKET DATA │
└─────────────────────────────────────────────────────────────────────────┘
ingest_live_minute_bars()
├─ Call Alpaca REST API: get latest 1-minute bars
├─ Insert into research/market.duckdb (bars table)
└─ Return: list of symbols with new data
ingest_daily_history()
├─ On startup or when needed:
│ ├─ Alpaca REST API: get daily bars (past 30 days)
│ ├─ Insert into research/market.duckdb (daily_bars table)
│ └─ Used by: gap%, RVOL calculations
└─ Return: daily bar snapshots
┌─────────────────────────────────────────────────────────────────────────┐
│ PHASE 2: DISCOVER & UPDATE WATCHLIST │
└─────────────────────────────────────────────────────────────────────────┘
ResearchWatchlistProvider.get_candidates(session_date)
├─ Check what symbols are in the watchlist for today
│ ├─ Manual symbols (from --symbols or WATCHER_SYMBOLS env)
│ ├─ Screener results (most-actives from Alpaca if --discover)
│ └─ RSS feed candidates (if configured)
├─ Return list of WatchCandidate objects (symbol, last_price, avg_volume)
└─ Watcher._states updated with new symbols
┌─────────────────────────────────────────────────────────────────────────┐
│ PHASE 3: EVALUATE SYMBOLS (CORE STRATEGY LOGIC) │
└─────────────────────────────────────────────────────────────────────────┘
Watcher.tick(bar_data, candidates)
│
└─ For each symbol in candidates:
│
├─ 1. FETCH BARS
│ └─ ResearchWatchlistProvider.get_bars(symbol, session_date)
│ └─ Query research DB for all minute bars from 9:30 AM to now
│
├─ 2. EMIT SYMBOL DISCOVERED (if new)
│ └─ EventStore.emit(SymbolDiscoveredEvent(symbol, source, ...))
│ └─ Event recorded with timestamp, session_id, payload
│
├─ 3. EVALUATE SETUP (if ≥10 bars available)
│ │
│ └─ evaluate_setup(bars, symbol) ← PURE STRATEGY ENGINE
│ │
│ ├─ Gap Check
│ │ └─ (today_open - prev_close) / prev_close ≥ 2% ?
│ │
│ ├─ Relative Volume (RVOL)
│ │ └─ (current 5-min avg vol / 20-day avg) ≥ 1.5 ?
│ │
│ ├─ Bull Flag Structure
│ │ └─ Pullback from high + ascending support pattern?
│ │
│ ├─ Price Action Quality
│ │ └─ Higher highs + higher lows? Clean bars?
│ │
│ ├─ 5 Other Criteria (tunable)
│ │ └─ Momentum, volatility, trend confirmation, etc.
│ │
│ ├─ Calculate SCORE
│ │ └─ (criteria_passed / total_criteria) * 100 = X%
│ │
│ └─ Return: {score, details, setup_data}
│
├─ 4. EMIT CRITERIA EVALUATED
│ └─ EventStore.emit(CriteriaEvaluatedEvent(symbol, criteria=[...], score))
│ └─ Each criterion: {name, passed, reason}
│
├─ 5. CHECK SIGNAL READY THRESHOLD
│ └─ IF score ≥ ready_score_pct (default 60%) AND NOT already_ready_this_session:
│ │
│ ├─ Emit SIGNAL READY
│ │ └─ EventStore.emit(SignalReadyEvent(symbol, entry, stop, target, ...))
│ │
│ ├─ Update symbol state: watching → ready
│ │ └─ EventStore.emit(SymbolStateChangedEvent(symbol, "watching" → "ready"))
│ │
│ └─ Mark symbol as already signaled (no duplicate signals this session)
│
├─ 6. CHECK SIGNAL BLOCKED
│ └─ ELSE IF score < ready_score_pct OR quality < min_quality:
│ │
│ ├─ Emit SIGNAL BLOCKED
│ │ └─ EventStore.emit(SignalBlockedEvent(symbol, reason, ...))
│ │
│ ├─ Update symbol state: watching → blocked
│ │ └─ EventStore.emit(SymbolStateChangedEvent(symbol, "watching" → "blocked"))
│ │
│ └─ RESET: allow signal_ready again if it recovers above threshold
│
└─ END for each symbol
┌─────────────────────────────────────────────────────────────────────────┐
│ PHASE 4: EXECUTION PIPELINE (signal_ready → approval → order) │
└─────────────────────────────────────────────────────────────────────────┘
TradingExecutionService.tick()
│
└─ Query event store for new signal_ready events (not yet processed)
│
└─ For each signal_ready:
│
├─ 1. CHECK RISK RULES
│ │
│ ├─ How many positions are open?
│ │ └─ IF open_positions ≥ max_concurrent_positions (e.g., 3):
│ │ └─ RiskRuleTriggeredEvent("max concurrent positions")
│ │ └─ SKIP this signal
│ │
│ ├─ Check daily loss circuit breaker
│ │ └─ IF realized_pnl_today ≤ -max_daily_loss_pct * account_equity:
│ │ └─ RiskRuleTriggeredEvent("daily loss exceeded")
│ │ └─ SKIP this signal (halt trading for the day)
│ │
│ └─ Max orders per tick
│ └─ Only submit max_orders_per_tick (e.g., 1) per tick to avoid order queue floods
│
├─ 2. CALCULATE POSITION SIZE
│ │
│ └─ calculate_position_size(entry, stop, account_equity, risk_per_trade_pct)
│ │
│ ├─ Risk per trade = account_equity * risk_per_trade_pct (e.g., 1%)
│ ├─ Risk per share = entry - stop
│ ├─ Shares = risk_per_trade / risk_per_share
│ └─ Return: {shares, entry, stop, target}
│
├─ 3. EMIT ORDER APPROVAL REQUESTED
│ └─ EventStore.emit(OrderApprovalRequestedEvent(
│ symbol, entry, stop, target, shares, risk_dollar, notional, ...
│ ))
│
├─ 4. AUTO-APPROVE OR QUEUE FOR MANUAL APPROVAL
│ │
│ ├─ IF --auto-approve or TRADING_AUTO_APPROVE=1:
│ │ │
│ │ ├─ Emit OrderApprovedEvent immediately
│ │ │ └─ EventStore.emit(OrderApprovedEvent(order_id, ...))
│ │ │
│ │ └─ Jump to Phase 5 (SUBMIT ORDER)
│ │
│ └─ ELSE (manual approval):
│ │
│ └─ Add to approval_queue in dashboard
│ └─ User sees: symbol, entry, stop, target, risk, share count, sparkline chart
│ └─ User clicks Approve or Reject
│ └─ Dashboard emits OrderApprovedEvent or OrderRejectedEvent
│
├─ 5. ON APPROVAL (if approved)
│ │
│ └─ TradingExecutionService detects OrderApprovedEvent
│ │
│ ├─ Create ExecutionRequest
│ │ └─ {symbol, entry, stop, target, shares, order_type: "limit"/"market", ...}
│ │
│ └─ Call AlpacaPaperExecutor.submit(request)
│ │
│ └─ AlpacaPaperExecutor.submit() [PHASE 5]
│
└─ END for each signal_ready
┌─────────────────────────────────────────────────────────────────────────┐
│ PHASE 5: BROKER ORDER SUBMISSION │
└─────────────────────────────────────────────────────────────────────────┘
AlpacaPaperExecutor.submit(execution_request)
│
├─ Create bracket order:
│ │
│ ├─ ENTRY ORDER
│ │ └─ Type: limit @ entry_price (restful, will fill on touch, can be cancelled)
│ │ OR market (fills immediately at market)
│ │
│ ├─ PROFIT TARGET
│ │ └─ Type: limit @ target_price
│ │ (target = entry + (entry - stop) * reward_multiple)
│ │
│ └─ STOP LOSS
│ └─ Type: stop @ stop_price (becomes market order when stop is hit)
│
├─ Submit to Alpaca REST API
│ └─ Returns: Alpaca order_id
│
├─ Emit OrderSubmittedEvent
│ └─ EventStore.emit(OrderSubmittedEvent(symbol, order_id, entry, stop, target, ...))
│
├─ Track armed entry in TradingExecutionService._armed dict
│ └─ _armed[order_id] = {symbol, entry_price, broker_order_id, armed_at, checks}
│
└─ Monitor for:
│
├─ Entry Fill
│ └─ If entry fills → position open → wait for target or stop
│
├─ Entry Timeout (if not filled after entry_timeout_bars minutes)
│ └─ Call Alpaca to cancel unfilled entry order
│
├─ Price Break Invalidation
│ └─ If current price < (entry - entry_invalidate_pct * entry):
│ └─ Cancel entry order (price broke back below entry)
│
└─ Full Fill (target or stop hit)
└─ Position closes → emit OrderFilledEvent
┌─────────────────────────────────────────────────────────────────────────┐
│ PHASE 6: ACCOUNT & POSITION SYNC │
└─────────────────────────────────────────────────────────────────────────┘
AlpacaPaperSync.sync_account()
│
├─ 1. GET ACCOUNT SUMMARY
│ └─ Alpaca REST: /account
│ └─ Returns: {equity, buying_power, multiplier, long_market_value, ...}
│
├─ 2. EMIT ACCOUNT SUMMARY UPDATED
│ └─ EventStore.emit(AccountSummaryUpdatedEvent(account_data))
│
├─ 3. GET OPEN POSITIONS
│ └─ Alpaca REST: /positions
│ └─ Returns: [{symbol, qty, avg_fill_price, current_price, unrealized_pl, ...}]
│
├─ 4. EMIT ACCOUNT POSITIONS UPDATED
│ └─ EventStore.emit(AccountPositionsUpdatedEvent(positions))
│
├─ 5. GET PENDING ORDERS
│ └─ Alpaca REST: /orders?status=open
│ └─ Returns: [{id, symbol, qty, type, status, filled_qty, ...}]
│
├─ 6. EMIT ACCOUNT ORDERS UPDATED
│ └─ EventStore.emit(AccountOrdersUpdatedEvent(orders))
│
└─ 7. CALCULATE SESSION P&L
└─ Query event store for all fills this session
├─ For each closed trade:
│ └─ realized_pnl = (exit_price - entry_price) * qty
├─ Aggregate:
│ └─ total_realized_pnl, total_unrealized_pnl, win_rate, avg_r, trade_count
└─ Used by dashboard P&L strip
┌─────────────────────────────────────────────────────────────────────────┐
│ PHASE 7: DASHBOARD UPDATES (READ-ONLY PROJECTIONS) │
└─────────────────────────────────────────────────────────────────────────┘
DashboardAPI (runs in background thread)
│
├─ SSE Stream Handler (/api/events)
│ └─ Pushes new events to all connected clients in real-time
│ └─ Falls back to polling if connection drops
│
├─ JSON Endpoints:
│ │
│ ├─ GET /api/approval-queue
│ │ └─ query_approval_queue(event_store)
│ │ └─ Returns: [{symbol, entry, stop, target, shares, risk_pct, notional, ...}]
│ │
│ ├─ GET /api/positions
│ │ └─ query_account_positions_snapshot(event_store)
│ │ └─ Returns: [{symbol, qty, entry, current, unrealized_pl, ...}]
│ │
│ ├─ GET /api/pnl
│ │ └─ query_pnl_snapshot(event_store)
│ │ └─ Returns: {realized, unrealized, total, win_rate, avg_r, open_count, closed_count}
│ │
│ ├─ GET /api/activity-feed
│ │ └─ query_activity_feed(event_store, limit=100)
│ │ └─ Returns: [{timestamp, event_type, message, symbol, ...}]
│ │
│ ├─ GET /api/symbol/:symbol/bars
│ │ └─ query_minute_bars(research_db, symbol, session_date)
│ │ └─ Returns: [{timestamp, open, high, low, close, volume}]
│ │ (for sparkline chart rendering)
│ │
│ └─ POST /api/approval/:order_id/approve|reject
│ └─ Emits OrderApprovedEvent or OrderRejectedEvent
│
└─ UI Features:
├─ P&L Strip (realized, unrealized, total, win rate, R multiple, counts)
├─ Approval Queue (cards with entry/stop/target preview, Approve/Reject buttons)
├─ Open Positions (list with live sparkline charts, P&L per position)
├─ Activity Feed (chronological log of all events)
├─ Symbol Details (expand to see 9 criteria: pass/fail/not-evaluated)
└─ Keyboard Shortcuts: A=Approve, R=Reject, S=Sound Toggle, /=Add Symbol, ?=Help
┌─────────────────────────────────────────────────────────────────────────┐
│ PHASE 8: CLEANUP & REPEAT │
└─────────────────────────────────────────────────────────────────────────┘
After all phases complete:
├─ Log tick summary (discovered, ready, blocked, evaluated, errors)
├─ IF --once: exit
└─ ELSE: sleep(60 seconds) and go back to PHASE 1
================================================================================
KEY CONCEPTS & INVARIANTS
================================================================================
EVENT SOURCING
• All state changes are recorded as immutable events in the append-only event store
• The current state is reconstructed by replaying events (projections)
• Every read operation queries the event store; no other state is authoritative
• Correlation ID = session_id (all events from one trading session have the same correlation_id)
SIGNAL DEBOUNCING
• Once a symbol emits signal_ready, it cannot emit another signal_ready until:
→ It falls back to signal_blocked (criteria fail), which resets the debounce
• Prevents duplicate signals from the same symbol in one session
STATE MACHINE (Watcher)
• discovered → watching → { ready | blocked } (can toggle between ready ↔ blocked)
• States are per-session; new session = fresh symbol states
BRACKET ORDER
• Entry (limit) + Profit Target (limit) + Stop Loss (stop) submitted as a single order
• If entry fills:
→ Two child orders activate (target and stop)
→ First one to fill = exit (other cancels automatically)
• If entry never fills:
→ Entry can timeout (entry_timeout_bars) or be cancelled on price break
RISK CONTROLS
• Max concurrent positions: once N positions open, no more entry orders until one closes
• Daily loss circuit breaker: once losses hit threshold, halt trading for the session
• Entry timeout: if entry rests >N minutes, cancel to avoid stale orders
• Price break invalidation: if price falls back >X% below entry, cancel entry (setup failed)
APPROVAL WORKFLOW
• Manual: user clicks Approve in dashboard → OrderApprovedEvent
• Auto: --auto-approve flag or TRADING_AUTO_APPROVE=1 env var → immediate approval
• Rejection: user clicks Reject or risk rule triggers → OrderRejectedEvent (queued signal removed)
================================================================================
TROUBLESHOOTING BY PHASE
================================================================================
Phase 1 (Ingest):
• No new bars? Check if Alpaca API keys are valid. Run --once in dry mode.
• Bars not in research DB? Check data/research/market.duckdb exists and is writable.
Phase 2 (Discover):
• No symbols in watchlist? Check --symbols / WATCHER_SYMBOLS env / most-actives screener.
Phase 3 (Evaluate):
• Symbols stuck in "watching"? Criteria too strict. Lower ready_score_pct or min_quality.
• Signals appear then disappear? They're being blocked. Check criteria_evaluated events.
• Check event store: SELECT * FROM events WHERE event_type = 'criteria_evaluated'
Phase 4 (Execution):
• Signal ready but no approval request? Risk rule triggered. Check RiskRuleTriggeredEvent.
• Approval request visible but user forgets to click? Use --auto-approve for testing.
Phase 5 (Broker):
• Order submitted but no fill? Entry price may be stale. Use market orders (entry_order_type).
• Entry stuck for >N minutes? Timeout will cancel it. Check entry_timeout_bars setting.
Phase 6 (Sync):
• Account sync fails? Alpaca API likely down. Dry mode still works.
• Positions don't match Alpaca? Event store is source of truth. Refresh browser.
Phase 7 (Dashboard):
• Dashboard not updating? Check browser console for errors. Try hard-refresh (Ctrl+Shift+R).
• SSE stream failing? Falls back to polling (/api/approval-queue, etc.). Check /api/events.
================================================================================
END
================================================================================
+9
View File
@@ -0,0 +1,9 @@
"""Momentum trading system.
Milestone 2: Event model and storage.
Milestone 3: Schwab integration + MOMENTUM_OS UI.
Milestone 4: Scanner execution loop + paper execution.
Milestone 5: Trading control room dashboard + live paper orchestrator.
"""
__version__ = "0.4.0"
+20
View File
@@ -0,0 +1,20 @@
"""Alpaca paper-trading integration (execution + account sync)."""
from alpaca_paper.settings import AlpacaPaperSettings
from alpaca_paper.client import AlpacaPaperClient, AlpacaApiError
from alpaca_paper.execution import (
AlpacaPaperExecutor,
ExecutionRequest,
ExecutionResult,
)
from alpaca_paper.sync import AlpacaPaperSync
__all__ = [
"AlpacaPaperSettings",
"AlpacaPaperClient",
"AlpacaApiError",
"AlpacaPaperExecutor",
"ExecutionRequest",
"ExecutionResult",
"AlpacaPaperSync",
]
+184
View File
@@ -0,0 +1,184 @@
"""Minimal Alpaca REST client (stdlib only — no SDK dependency).
Covers what the system needs: account, positions, orders, order submission
and cancellation, minute bars, latest trades, and the most-actives screener.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from alpaca_paper.settings import AlpacaPaperSettings
logger = logging.getLogger(__name__)
class AlpacaApiError(RuntimeError):
def __init__(self, status: int, body: str):
super().__init__(f"Alpaca API error {status}: {body[:300]}")
self.status = status
self.body = body
class AlpacaPaperClient:
def __init__(self, settings: AlpacaPaperSettings | None = None, timeout: int = 15):
self.settings = settings or AlpacaPaperSettings.from_env()
self.timeout = timeout
# -- low level -------------------------------------------------------
def _headers(self) -> dict[str, str]:
return {
"APCA-API-KEY-ID": self.settings.api_key,
"APCA-API-SECRET-KEY": self.settings.secret_key,
"Content-Type": "application/json",
}
def _request(
self, method: str, url: str, params: dict | None = None, body: dict | None = None
):
if params:
qs = urllib.parse.urlencode(
{k: v for k, v in params.items() if v is not None}
)
url = f"{url}?{qs}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url, data=data, headers=self._headers(), method=method
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read().decode()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
raise AlpacaApiError(exc.code, exc.read().decode(errors="replace")) from exc
def _trading(self, method: str, path: str, **kw):
return self._request(
method, f"{self.settings.trading_base_url}/v2{path}", **kw
)
def _data(self, method: str, path: str, **kw):
return self._request(method, f"{self.settings.data_base_url}{path}", **kw)
# -- trading API -------------------------------------------------------
def get_account(self) -> dict:
return self._trading("GET", "/account")
def get_positions(self) -> list[dict]:
return self._trading("GET", "/positions")
def get_orders(self, status: str = "all", limit: int = 100) -> list[dict]:
return self._trading(
"GET", "/orders", params={"status": status, "limit": limit}
)
def submit_order(
self,
symbol: str,
qty: int,
side: str = "buy",
order_type: str = "market",
time_in_force: str = "day",
limit_price: float | None = None,
stop_loss_price: float | None = None,
take_profit_price: float | None = None,
client_order_id: str | None = None,
) -> dict:
body: dict = {
"symbol": symbol,
"qty": str(qty),
"side": side,
"type": order_type,
"time_in_force": time_in_force,
}
if limit_price is not None:
body["limit_price"] = str(round(limit_price, 2))
if client_order_id:
body["client_order_id"] = client_order_id
if stop_loss_price is not None or take_profit_price is not None:
body["order_class"] = "bracket" if (
stop_loss_price and take_profit_price
) else "oto"
if stop_loss_price is not None:
body["stop_loss"] = {"stop_price": str(round(stop_loss_price, 2))}
if take_profit_price is not None:
body["take_profit"] = {"limit_price": str(round(take_profit_price, 2))}
return self._trading("POST", "/orders", body=body)
def cancel_order(self, order_id: str) -> None:
self._trading("DELETE", f"/orders/{order_id}")
def close_position(self, symbol: str) -> dict:
return self._trading("DELETE", f"/positions/{symbol}")
# -- market data API ----------------------------------------------------
def get_minute_bars(
self,
symbols: list[str],
start_iso: str,
end_iso: str | None = None,
limit: int = 10_000,
feed: str | None = None,
) -> dict[str, list[dict]]:
"""Fetch 1-minute bars for symbols, following pagination tokens."""
all_bars: dict[str, list[dict]] = {s: [] for s in symbols}
page_token = None
while True:
params = {
"symbols": ",".join(symbols),
"timeframe": "1Min",
"start": start_iso,
"end": end_iso,
"limit": limit,
"feed": feed or self.settings.feed,
"adjustment": "raw",
"page_token": page_token,
}
payload = self._data("GET", "/v2/stocks/bars", params=params)
for symbol, bars in (payload.get("bars") or {}).items():
all_bars.setdefault(symbol, []).extend(bars or [])
page_token = payload.get("next_page_token")
if not page_token:
break
return all_bars
def get_latest_trades(self, symbols: list[str]) -> dict[str, dict]:
payload = self._data(
"GET",
"/v2/stocks/trades/latest",
params={"symbols": ",".join(symbols), "feed": self.settings.feed},
)
return payload.get("trades") or {}
def get_most_actives(self, top: int = 20, by: str = "volume") -> list[dict]:
payload = self._data(
"GET",
"/v1beta1/screener/stocks/most-actives",
params={"top": top, "by": by},
)
return payload.get("most_actives") or []
def get_daily_bars(
self, symbols: list[str], start_iso: str, end_iso: str | None = None
) -> dict[str, list[dict]]:
payload = self._data(
"GET",
"/v2/stocks/bars",
params={
"symbols": ",".join(symbols),
"timeframe": "1Day",
"start": start_iso,
"end": end_iso,
"limit": 10_000,
"feed": self.settings.feed,
"adjustment": "raw",
},
)
return payload.get("bars") or {}
+223
View File
@@ -0,0 +1,223 @@
"""Alpaca paper execution bridge.
Turns an approved execution request into a real Alpaca paper order and
emits order lifecycle events into the event store.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime
from alpaca_paper.client import AlpacaApiError, AlpacaPaperClient
from storage.event_schema import (
EventMode,
OrderCancelledEvent,
OrderFilledEvent,
OrderSubmittedEvent,
)
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
@dataclass
class ExecutionRequest:
"""Broker-agnostic order request derived from a signal."""
symbol: str
side: str
quantity: int
entry_price: float | None = None
stop_loss_price: float | None = None
take_profit_price: float | None = None
order_type: str = "market"
time_in_force: str = "day"
order_id: str = ""
def __post_init__(self):
if not self.order_id:
self.order_id = str(uuid.uuid4())
def to_payload(self) -> dict:
return {
"symbol": self.symbol,
"side": self.side,
"quantity": self.quantity,
"entry_price": self.entry_price,
"stop_loss_price": self.stop_loss_price,
"take_profit_price": self.take_profit_price,
"order_type": self.order_type,
"time_in_force": self.time_in_force,
"order_id": self.order_id,
}
@classmethod
def from_payload(cls, payload: dict) -> "ExecutionRequest":
return cls(
symbol=payload["symbol"],
side=payload.get("side", "buy"),
quantity=int(payload.get("quantity", 0)),
entry_price=payload.get("entry_price"),
stop_loss_price=payload.get("stop_loss_price"),
take_profit_price=payload.get("take_profit_price"),
order_type=payload.get("order_type", "market"),
time_in_force=payload.get("time_in_force", "day"),
order_id=str(payload.get("order_id") or ""),
)
@dataclass
class ExecutionResult:
ok: bool
order_id: str
broker_order_id: str | None = None
status: str | None = None
error: str | None = None
raw: dict | None = None
class AlpacaPaperExecutor:
"""Submits execution requests to the Alpaca paper account."""
broker_name = "alpaca_paper"
def __init__(
self,
store: EventStore,
client: AlpacaPaperClient | None = None,
session_id: str | None = None,
mode: EventMode = EventMode.PAPER,
):
self.store = store
self.client = client or AlpacaPaperClient()
self.session_id = session_id
self.mode = mode
def execute(self, request: ExecutionRequest) -> ExecutionResult:
if request.quantity <= 0:
return ExecutionResult(
ok=False, order_id=request.order_id, error="quantity must be > 0"
)
try:
raw = self.client.submit_order(
symbol=request.symbol,
qty=request.quantity,
side=request.side,
order_type=request.order_type,
time_in_force=request.time_in_force,
limit_price=request.entry_price
if request.order_type == "limit"
else None,
stop_loss_price=request.stop_loss_price,
take_profit_price=request.take_profit_price,
client_order_id=request.order_id[:48],
)
except AlpacaApiError as exc:
logger.error("alpaca order failed: %s", exc)
self._emit_cancelled(request, f"broker rejected: {exc}")
return ExecutionResult(
ok=False, order_id=request.order_id, error=str(exc)
)
except Exception as exc:
logger.exception("alpaca order failed")
self._emit_cancelled(request, f"submit error: {exc}")
return ExecutionResult(
ok=False, order_id=request.order_id, error=str(exc)
)
broker_order_id = raw.get("id")
status = raw.get("status")
self.store.emit(
OrderSubmittedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=(
f"Submitted {request.side} {request.quantity} {request.symbol} "
f"to alpaca_paper ({status})"
),
symbol=request.symbol,
order_id=request.order_id,
side=request.side,
quantity=request.quantity,
price=request.entry_price or 0.0,
payload={
"symbol": request.symbol,
"order_id": request.order_id,
"side": request.side,
"quantity": request.quantity,
"price": request.entry_price,
"broker_order_id": broker_order_id,
"broker_status": status,
},
)
)
if status == "filled":
fill_price = float(raw.get("filled_avg_price") or 0) or None
self.store.emit(
OrderFilledEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Filled {request.symbol} @ {fill_price}",
symbol=request.symbol,
order_id=request.order_id,
fill_price=fill_price or 0.0,
fill_quantity=int(float(raw.get("filled_qty") or 0)),
payload={
"symbol": request.symbol,
"order_id": request.order_id,
"fill_price": fill_price,
"fill_quantity": int(float(raw.get("filled_qty") or 0)),
},
)
)
return ExecutionResult(
ok=True,
order_id=request.order_id,
broker_order_id=broker_order_id,
status=status,
raw=raw,
)
def cancel_entry(
self, order_id: str, broker_order_id: str | None, symbol: str, reason: str
) -> ExecutionResult:
"""Cancel a resting (unfilled) entry order at the broker and record it."""
try:
if broker_order_id:
self.client.cancel_order(broker_order_id)
except AlpacaApiError as exc:
# already filled/cancelled at the broker, or transient — log and
# still emit the cancel so our state stops tracking it
logger.warning("cancel of %s returned: %s", order_id, exc)
except Exception as exc: # noqa: BLE001
logger.warning("cancel of %s errored: %s", order_id, exc)
self._emit_cancelled(
ExecutionRequest(symbol=symbol, side="buy", quantity=0, order_id=order_id),
reason,
)
return ExecutionResult(ok=True, order_id=order_id, status="cancelled")
def _emit_cancelled(self, request: ExecutionRequest, reason: str) -> None:
self.store.emit(
OrderCancelledEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Order for {request.symbol} cancelled: {reason}",
symbol=request.symbol,
order_id=request.order_id,
cancel_reason=reason,
payload={
"symbol": request.symbol,
"order_id": request.order_id,
"cancel_reason": reason,
},
)
)
+39
View File
@@ -0,0 +1,39 @@
"""Alpaca paper-trading settings."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class AlpacaPaperSettings:
api_key: str = ""
secret_key: str = ""
trading_base_url: str = "https://paper-api.alpaca.markets"
data_base_url: str = "https://data.alpaca.markets"
feed: str = "iex"
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "AlpacaPaperSettings":
values = dict(os.environ)
if env is not None:
values.update(env)
api_key = values.get("ALPACA_API_KEY") or values.get("APCA_API_KEY_ID") or ""
secret = (
values.get("ALPACA_SECRET_KEY")
or values.get("APCA_API_SECRET_KEY")
or ""
)
base = values.get("APCA_API_BASE_URL", "https://paper-api.alpaca.markets")
feed = values.get("ALPACA_DATA_FEED", "iex")
return cls(
api_key=api_key,
secret_key=secret,
trading_base_url=base.rstrip("/"),
feed=feed,
)
@property
def is_configured(self) -> bool:
return bool(self.api_key and self.secret_key)
+135
View File
@@ -0,0 +1,135 @@
"""Sync the Alpaca paper account into the event store.
Emits account_summary_updated / account_positions_updated /
account_orders_updated events that the dashboard projections read.
"""
from __future__ import annotations
import logging
from datetime import datetime
from alpaca_paper.client import AlpacaPaperClient
from storage.event_schema import (
AccountOrdersUpdatedEvent,
AccountPositionsUpdatedEvent,
AccountSummaryUpdatedEvent,
EventMode,
)
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
BROKER_NAME = "alpaca_paper"
class AlpacaPaperSync:
def __init__(
self,
store: EventStore,
client: AlpacaPaperClient | None = None,
session_id: str | None = None,
mode: EventMode = EventMode.PAPER,
):
self.store = store
self.client = client or AlpacaPaperClient()
self.session_id = session_id
self.mode = mode
def sync_account(self) -> dict | None:
try:
account = self.client.get_account()
except Exception:
logger.exception("alpaca account sync failed")
return None
account_id = str(account.get("account_number") or account.get("id") or "paper")
self.store.emit(
AccountSummaryUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Alpaca paper equity {account.get('equity')}",
broker_name=BROKER_NAME,
account_id=account_id,
account_desc="Alpaca Paper",
total_equity=float(account.get("equity") or 0),
cash_balance=float(account.get("cash") or 0),
buying_power=float(account.get("buying_power") or 0),
net_liquidating_value=float(account.get("equity") or 0),
)
)
return account
def sync_positions(self) -> list[dict] | None:
try:
raw = self.client.get_positions()
except Exception:
logger.exception("alpaca positions sync failed")
return None
positions = [
{
"symbol": p.get("symbol"),
"quantity": float(p.get("qty") or 0),
"avg_entry_price": float(p.get("avg_entry_price") or 0),
"current_price": float(p.get("current_price") or 0),
"market_value": float(p.get("market_value") or 0),
"unrealized_pnl": float(p.get("unrealized_pl") or 0),
"unrealized_pnl_pct": float(p.get("unrealized_plpc") or 0),
"side": p.get("side"),
}
for p in raw
]
self.store.emit(
AccountPositionsUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Alpaca paper positions: {len(positions)}",
broker_name=BROKER_NAME,
account_id="paper",
positions=positions,
)
)
return positions
def sync_orders(self, status: str = "all", limit: int = 50) -> list[dict] | None:
try:
raw = self.client.get_orders(status=status, limit=limit)
except Exception:
logger.exception("alpaca orders sync failed")
return None
orders = [
{
"broker_order_id": o.get("id"),
"client_order_id": o.get("client_order_id"),
"symbol": o.get("symbol"),
"side": o.get("side"),
"quantity": float(o.get("qty") or 0),
"filled_quantity": float(o.get("filled_qty") or 0),
"type": o.get("type"),
"status": o.get("status"),
"limit_price": o.get("limit_price"),
"filled_avg_price": o.get("filled_avg_price"),
"submitted_at": o.get("submitted_at"),
}
for o in raw
]
self.store.emit(
AccountOrdersUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Alpaca paper orders: {len(orders)}",
broker_name=BROKER_NAME,
account_id="paper",
orders=orders,
)
)
return orders
def sync_all(self) -> dict:
return {
"account": self.sync_account(),
"positions": self.sync_positions(),
"orders": self.sync_orders(),
}
View File
Whitespace-only changes.
+387
View File
@@ -0,0 +1,387 @@
"""Dashboard API (Milestone 5) — stdlib ThreadingHTTPServer, no FastAPI.
Endpoints
---------
GET / dashboard HTML
GET /api/snapshots everything the dashboard needs (one shot)
GET /api/stream Server-Sent Events: pushes snapshots on change
GET /api/approval-queue pending approvals only
GET /api/criteria?symbol=XYZ per-criterion pass/fail breakdown
GET /api/bars?symbol=XYZ[&minutes=60] recent 1-min bars for a sparkline
POST /api/trading/approvals/approve {"order_id": ..., "approved_by"?: ...}
POST /api/trading/approvals/reject {"order_id": ..., "reason"?: ...}
POST /api/trading/exit-order {"symbol": ...}
POST /api/watch/add {"symbol": ...} (manual watchlist inject)
The server holds an EventStore for reads; trade actions route through a
TradingExecutionService when one is attached (the orchestrator does this).
Without one, action endpoints return 503 instead of crashing, so the
dashboard still works read-only.
"""
from __future__ import annotations
import json
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from storage.event_store import EventStore
from storage.projections import (
query_account_orders_snapshot,
query_account_positions_snapshot,
query_account_summary_snapshot,
query_approval_queue,
query_fills_feed,
query_order_lifecycle_snapshot,
query_ready_signals_snapshot,
query_session_pnl,
query_session_summary,
query_symbol_criteria,
query_watch_states_snapshot,
)
STATIC_DIR = Path(__file__).parent / "static"
def _eastern_session_date():
"""Today's date in US/Eastern — matches the ingestion pipeline's flags."""
from zoneinfo import ZoneInfo
return datetime.now(ZoneInfo("America/New_York")).date()
class DashboardState:
"""Shared state: event store path + optional live execution service."""
def __init__(
self,
event_db_path: str,
execution_service=None,
execution_mode: str = "alpaca_paper",
research_con=None,
watch_inject=None,
) -> None:
self.event_db_path = event_db_path
self.execution_service = execution_service
self.execution_mode = execution_mode
# optional research DB connection + callback for charts / manual inject
self.research_con = research_con
self.watch_inject = watch_inject
self._lock = threading.Lock()
def open_store(self) -> EventStore:
return EventStore(self.event_db_path)
def snapshots(self) -> dict:
with self._lock:
store = self.open_store()
try:
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"execution_mode": self.execution_mode,
"has_execution": self.execution_service is not None,
"pnl": query_session_pnl(store),
"approval_queue": query_approval_queue(store),
"ready_signals": query_ready_signals_snapshot(store),
"watch_states": query_watch_states_snapshot(store),
"accounts": query_account_summary_snapshot(store),
"positions": query_account_positions_snapshot(store),
"orders": query_account_orders_snapshot(store),
"order_lifecycle": query_order_lifecycle_snapshot(store),
"fills": query_fills_feed(store, limit=30),
"sessions": query_session_summary(store),
}
finally:
store.close()
def event_count(self) -> int:
with self._lock:
store = self.open_store()
try:
return store.count_events()
finally:
store.close()
def approval_queue(self) -> list[dict]:
with self._lock:
store = self.open_store()
try:
return query_approval_queue(store)
finally:
store.close()
def criteria(self, symbol: str) -> dict:
with self._lock:
store = self.open_store()
try:
return query_symbol_criteria(store, symbol)
finally:
store.close()
def bars(self, symbol: str, minutes: int = 60) -> dict:
"""Recent 1-min bars for a sparkline, from the research DB if present.
Uses the current US/Eastern session date (matching the ingestion
pipeline). If today has no bars yet (pre-market, weekend, or replay of
an older session), falls back to the most recent session that does.
"""
if self.research_con is None:
return {"symbol": symbol, "bars": [], "reason": "no research db attached"}
from research.query import query_minute_bars
with self._lock:
try:
session_date = _eastern_session_date()
df = query_minute_bars(self.research_con, symbol, session_date)
if df is None or df.empty:
# fall back to the latest session with bars for this symbol
row = self.research_con.execute(
"SELECT MAX(session_date) FROM minute_bars WHERE symbol = ?",
[symbol],
).fetchone()
if row and row[0] is not None:
df = query_minute_bars(self.research_con, symbol, row[0])
except Exception as exc: # noqa: BLE001
return {"symbol": symbol, "bars": [], "reason": str(exc)}
if df is None or df.empty:
return {"symbol": symbol, "bars": []}
tail = df.tail(minutes)
bars = [
{
"t": str(row["timestamp"]),
"c": float(row["close"]),
"h": float(row["high"]),
"l": float(row["low"]),
"v": int(row["volume"]),
"vwap": float(row["vwap"]) if row.get("vwap") is not None else None,
}
for _, row in tail.iterrows()
]
return {"symbol": symbol, "bars": bars}
def _with_service(self, fn):
if self.execution_service is None:
return None
with self._lock:
return fn(self.execution_service)
def approve(self, order_id: str, approved_by: str, notes: str | None) -> dict | None:
return self._with_service(
lambda svc: svc.approve_order(order_id, approved_by=approved_by, notes=notes)
)
def reject(self, order_id: str, rejected_by: str, reason: str) -> dict | None:
return self._with_service(
lambda svc: svc.reject_order(order_id, rejected_by=rejected_by, reason=reason)
)
def exit_position(self, symbol: str) -> dict | None:
return self._with_service(lambda svc: svc.submit_exit_order(symbol))
def add_watch(self, symbol: str) -> dict | None:
"""Manually inject a symbol onto the watchlist (if supported)."""
if not self.watch_inject or not symbol:
return None
try:
self.watch_inject(symbol.upper())
return {"ok": True, "symbol": symbol.upper()}
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": str(exc)}
def _json_default(obj):
if isinstance(obj, datetime):
return obj.isoformat()
return str(obj)
def _dump(payload) -> bytes:
return json.dumps(payload, default=_json_default).encode()
class DashboardHandler(BaseHTTPRequestHandler):
state: DashboardState # injected via make_handler
protocol_version = "HTTP/1.1"
# -- plumbing -----------------------------------------------------------
def log_message(self, fmt, *args): # noqa: A003 - quiet by default
pass
def _send_json(self, payload, status: int = 200) -> None:
body = _dump(payload)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _send_html(self, path: Path) -> None:
if not path.exists():
self._send_json({"error": "dashboard.html not found"}, 404)
return
body = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_body(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode() or "{}")
except json.JSONDecodeError:
return {}
def _query(self) -> dict:
return parse_qs(urlparse(self.path).query)
# -- SSE ----------------------------------------------------------------
def _stream(self) -> None:
"""Server-Sent Events: push a fresh snapshot whenever events change.
Falls back gracefully — a heartbeat every ~15s keeps proxies open even
when nothing is happening, and the loop exits when the client drops or
the server signals shutdown (so the DuckDB handle is released cleanly).
"""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
shutdown = getattr(type(self), "shutdown_event", None)
last_count = -1
last_heartbeat = 0.0
try:
while shutdown is None or not shutdown.is_set():
count = self.state.event_count()
now = time.monotonic()
if count != last_count:
last_count = count
payload = _dump(self.state.snapshots()).decode()
self.wfile.write(f"event: snapshot\ndata: {payload}\n\n".encode())
self.wfile.flush()
last_heartbeat = now
elif now - last_heartbeat > 15:
self.wfile.write(b": heartbeat\n\n")
self.wfile.flush()
last_heartbeat = now
# sleep in short slices so shutdown is responsive
for _ in range(10):
if shutdown is not None and shutdown.is_set():
break
time.sleep(0.1)
except (BrokenPipeError, ConnectionResetError, OSError):
return # client disconnected
# -- routes ---------------------------------------------------------------
def do_GET(self): # noqa: N802
try:
route = urlparse(self.path).path
if route in ("/", "/index.html", "/dashboard"):
self._send_html(STATIC_DIR / "dashboard.html")
elif route == "/api/stream":
self._stream()
elif route == "/api/snapshots":
self._send_json(self.state.snapshots())
elif route == "/api/approval-queue":
self._send_json({"approval_queue": self.state.approval_queue()})
elif route == "/api/criteria":
symbol = (self._query().get("symbol") or [""])[0]
self._send_json(self.state.criteria(symbol))
elif route == "/api/bars":
q = self._query()
symbol = (q.get("symbol") or [""])[0]
minutes = int((q.get("minutes") or ["60"])[0])
self._send_json(self.state.bars(symbol, minutes))
elif route == "/api/health":
self._send_json({"ok": True, "mode": self.state.execution_mode})
else:
self._send_json({"error": "not found"}, 404)
except Exception as exc: # noqa: BLE001
self._send_json({"error": str(exc)}, 500)
def do_POST(self): # noqa: N802
try:
route = urlparse(self.path).path
body = self._read_body()
if route == "/api/trading/approvals/approve":
result = self.state.approve(
str(body.get("order_id") or ""),
approved_by=str(body.get("approved_by") or "dashboard"),
notes=body.get("notes"),
)
elif route == "/api/trading/approvals/reject":
result = self.state.reject(
str(body.get("order_id") or ""),
rejected_by=str(body.get("rejected_by") or "dashboard"),
reason=str(body.get("reason") or "manual"),
)
elif route == "/api/trading/exit-order":
result = self.state.exit_position(str(body.get("symbol") or ""))
elif route == "/api/watch/add":
result = self.state.add_watch(str(body.get("symbol") or ""))
else:
self._send_json({"error": "not found"}, 404)
return
if result is None:
self._send_json(
{"ok": False, "error": "not available in read-only mode"}, 503
)
else:
self._send_json(result, 200 if result.get("ok") else 400)
except Exception as exc: # noqa: BLE001
self._send_json({"error": str(exc)}, 500)
def do_OPTIONS(self): # noqa: N802
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def make_handler(state: DashboardState, shutdown_event=None):
return type(
"BoundDashboardHandler",
(DashboardHandler,),
{"state": state, "shutdown_event": shutdown_event},
)
def create_server(
state: DashboardState, host: str = "127.0.0.1", port: int = 8010
) -> ThreadingHTTPServer:
shutdown_event = threading.Event()
server = ThreadingHTTPServer((host, port), make_handler(state, shutdown_event))
# let SSE loops notice shutdown and release their DuckDB handles cleanly
server._sse_shutdown = shutdown_event # type: ignore[attr-defined]
_orig_shutdown = server.shutdown
def _shutdown():
shutdown_event.set()
_orig_shutdown()
server.shutdown = _shutdown # type: ignore[method-assign]
return server
def serve_in_background(
state: DashboardState, host: str = "127.0.0.1", port: int = 8010
) -> tuple[ThreadingHTTPServer, threading.Thread]:
server = create_server(state, host, port)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread
+598
View File
@@ -0,0 +1,598 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Momentum — Trading Control Room</title>
<style>
:root {
--bg: #0b0e14;
--panel: #11151f;
--panel-2: #161b28;
--border: #1f2636;
--text: #d7dce6;
--muted: #76809a;
--accent: #4f9cf9;
--green: #2fbf71;
--red: #e5484d;
--amber: #f5a524;
--purple: #b89ad6;
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
}
header {
display: flex; align-items: center; gap: 14px;
padding: 12px 22px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #10141e, #0b0e14);
position: sticky; top: 0; z-index: 20;
}
header h1 { font-size: 16px; font-weight: 650; letter-spacing: .4px; }
header h1 span { color: var(--accent); }
.badge {
font: 600 11px/1 var(--mono); letter-spacing: .8px; text-transform: uppercase;
padding: 5px 10px; border-radius: 4px; border: 1px solid;
}
.badge.paper { color: var(--accent); border-color: #2a4a78; background: #12243d; }
.badge.live { color: #ff8589; border-color: #6e2226; background: #341417; }
.spacer { flex: 1; }
.stamp { color: var(--muted); font: 12px var(--mono); }
.conn { display: flex; align-items: center; gap: 7px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); transition: background .3s; }
.dot.live { background: var(--green); box-shadow: 0 0 8px rgba(47,191,113,.6); animation: pulse 2s infinite; }
.dot.err { background: var(--red); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
.hdr-btn {
font: 600 11px var(--mono); letter-spacing: .5px;
background: var(--panel-2); color: var(--muted);
border: 1px solid var(--border); border-radius: 4px;
padding: 5px 9px; cursor: pointer;
}
.hdr-btn.on { color: var(--accent); border-color: #2a4a78; }
/* P&L strip */
.pnl-strip {
display: flex; gap: 0; flex-wrap: wrap;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.pnl-cell {
flex: 1; min-width: 120px;
padding: 10px 18px;
border-right: 1px solid var(--border);
}
.pnl-cell .label { font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .7px; color: var(--muted); }
.pnl-cell .value { font: 650 20px var(--mono); margin-top: 3px; }
.pos { color: var(--green); } .neg { color: var(--red); }
main {
display: grid; grid-template-columns: repeat(12, 1fr);
gap: 14px; padding: 16px 22px 56px; max-width: 1600px; margin: 0 auto;
}
.panel {
grid-column: span 6; background: var(--panel);
border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
}
.panel.full { grid-column: span 12; }
.panel h2 {
font-size: 12px; font-weight: 650; letter-spacing: 1px; text-transform: uppercase;
color: var(--muted); padding: 11px 14px; border-bottom: 1px solid var(--border);
background: var(--panel-2); display: flex; align-items: center; gap: 8px;
}
.count { font: 600 11px var(--mono); background: var(--border); color: var(--text); border-radius: 10px; padding: 1px 8px; }
.hint { margin-left: auto; font: 500 10px var(--mono); color: var(--muted); letter-spacing: .3px; text-transform: none; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font: 600 11px var(--mono); text-transform: uppercase; letter-spacing: .6px; color: var(--muted); padding: 8px 14px; border-bottom: 1px solid var(--border); }
td { padding: 8px 14px; border-bottom: 1px solid #151a26; font-size: 13px; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
.num { font-family: var(--mono); }
.sym { font: 600 13px var(--mono); color: #e9edf5; }
.empty { color: var(--muted); padding: 18px 14px; font-size: 13px; }
.pill { display: inline-block; font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .6px; padding: 3px 8px; border-radius: 3px; }
.pill.ready { background: #12362a; color: var(--green); }
.pill.watching { background: #14283f; color: var(--accent); }
.pill.blocked { background: #38221a; color: var(--amber); }
.pill.late { background: #2a2330; color: var(--purple); }
.pill.discovered { background: #1c2230; color: var(--muted); }
.pill.long { background: #12362a; color: var(--green); }
.pill.short { background: #3a191c; color: #ff8589; }
button { font: 600 12px -apple-system, "Segoe UI", sans-serif; border: 1px solid transparent; border-radius: 5px; padding: 6px 12px; cursor: pointer; transition: filter .12s; }
button:hover { filter: brightness(1.18); }
button:disabled { opacity: .45; cursor: default; }
.btn-approve { background: #15452f; color: #6ee7a8; border-color: #1f6b48; }
.btn-reject { background: #3c1d20; color: #ff9296; border-color: #6e2226; }
.btn-exit { background: #3a2a12; color: #ffc456; border-color: #6b4e1f; }
.actions { display: flex; gap: 6px; }
/* sparkline canvas */
.spark { display: block; width: 132px; height: 38px; }
.spark-wrap { position: relative; }
.spark-meta { font: 500 9px var(--mono); color: var(--muted); margin-top: 1px; }
/* expandable criteria row */
.expandable { cursor: pointer; }
.expandable:hover { background: #131826; }
.caret { display: inline-block; width: 10px; color: var(--muted); transition: transform .15s; }
.caret.open { transform: rotate(90deg); }
.criteria-cell { padding: 0 !important; background: #0d111a; }
.criteria-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; padding: 12px 16px; }
.crit { display: flex; align-items: center; gap: 7px; font: 500 12px var(--mono); }
.crit .mark { width: 15px; height: 15px; border-radius: 3px; display: grid; place-items: center; font-size: 10px; font-weight: 700; flex-shrink: 0; }
.crit.pass .mark { background: #12362a; color: var(--green); }
.crit.fail .mark { background: #38221a; color: var(--amber); }
.crit.skip .mark { background: #1c2230; color: var(--muted); }
.crit.fail { color: #c9a98a; }
.crit.skip { color: var(--muted); }
/* risk preview chip */
.risk { font: 500 11px var(--mono); color: var(--muted); }
.risk b { color: var(--text); font-weight: 600; }
/* activity feed */
.feed-row { display: flex; align-items: center; gap: 10px; padding: 7px 14px; border-bottom: 1px solid #151a26; font: 500 12px var(--mono); }
.feed-row:last-child { border-bottom: none; }
.feed-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.feed-time { color: var(--muted); font-size: 11px; min-width: 64px; }
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); padding: 11px 16px; border-radius: 6px; font-size: 13px; max-width: 380px; opacity: 0; transform: translateY(8px); transition: all .25s; pointer-events: none; z-index: 50; }
.toast.show { opacity: 1; transform: none; }
.toast.err { border-left-color: var(--red); }
.toast.ok { border-left-color: var(--green); }
.acct-row { display: flex; gap: 26px; padding: 12px 14px; flex-wrap: wrap; }
.metric { min-width: 120px; }
.metric .label { font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .7px; color: var(--muted); }
.metric .value { font: 600 17px var(--mono); margin-top: 2px; }
.inject { display: flex; gap: 6px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
.inject input { flex: 1; background: #0d111a; border: 1px solid var(--border); border-radius: 5px; color: var(--text); padding: 6px 10px; font: 600 12px var(--mono); text-transform: uppercase; }
.inject input:focus { outline: none; border-color: var(--accent); }
.inject button { background: #14283f; color: var(--accent); border-color: #2a4a78; }
/* keyboard help overlay */
.kbd-help { position: fixed; inset: 0; background: rgba(6,8,12,.82); display: none; place-items: center; z-index: 60; }
.kbd-help.show { display: grid; }
.kbd-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 24px 28px; max-width: 420px; }
.kbd-card h3 { font-size: 14px; margin-bottom: 14px; letter-spacing: .5px; }
.kbd-card .row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; color: var(--muted); }
kbd { background: var(--panel-2); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 2px 7px; font: 600 12px var(--mono); color: var(--text); }
@media (max-width: 1040px) { .panel { grid-column: span 12; } }
</style>
</head>
<body>
<header>
<h1>MOMENTUM <span>/ control room</span></h1>
<span id="mode-badge" class="badge paper">PAPER</span>
<div class="spacer"></div>
<button id="sound-toggle" class="hdr-btn" title="Toggle signal alert sound">SOUND OFF</button>
<button id="kbd-toggle" class="hdr-btn" title="Keyboard shortcuts (?)">KEYS</button>
<div class="conn"><div id="conn-dot" class="dot"></div><span id="stamp" class="stamp">connecting...</span></div>
</header>
<div class="pnl-strip" id="pnl-strip">
<div class="pnl-cell"><div class="label">Total P&amp;L</div><div class="value num" id="pnl-total">—</div></div>
<div class="pnl-cell"><div class="label">Realized</div><div class="value num" id="pnl-realized">—</div></div>
<div class="pnl-cell"><div class="label">Unrealized</div><div class="value num" id="pnl-unrealized">—</div></div>
<div class="pnl-cell"><div class="label">Win Rate</div><div class="value num" id="pnl-winrate">—</div></div>
<div class="pnl-cell"><div class="label">Avg R</div><div class="value num" id="pnl-avgr">—</div></div>
<div class="pnl-cell"><div class="label">Open / Closed</div><div class="value num" id="pnl-counts">—</div></div>
</div>
<main>
<section class="panel full">
<h2>Account <span id="acct-count" class="count">0</span></h2>
<div id="accounts" class="acct-row"><span class="empty">No account snapshot yet — waiting for broker sync.</span></div>
</section>
<section class="panel">
<h2>Approval Queue <span id="aq-count" class="count">0</span><span class="hint">A approve top &middot; R reject top</span></h2>
<div id="approval-queue"></div>
</section>
<section class="panel">
<h2>Ready Signals <span id="rs-count" class="count">0</span></h2>
<div id="ready-signals"></div>
</section>
<section class="panel">
<h2>Open Positions <span id="pos-count" class="count">0</span></h2>
<div id="positions"></div>
</section>
<section class="panel">
<h2>Watch States <span id="ws-count" class="count">0</span><span class="hint">click a row for criteria</span></h2>
<div class="inject">
<input id="inject-input" placeholder="add symbol..." maxlength="6" autocomplete="off">
<button id="inject-btn">Watch</button>
</div>
<div id="watch-states"></div>
</section>
<section class="panel">
<h2>Activity Feed <span id="feed-count" class="count">0</span></h2>
<div id="feed"></div>
</section>
<section class="panel">
<h2>Order Lifecycle <span id="ol-count" class="count">0</span></h2>
<div id="order-lifecycle"></div>
</section>
</main>
<div id="toast" class="toast"></div>
<div id="kbd-help" class="kbd-help">
<div class="kbd-card">
<h3>Keyboard shortcuts</h3>
<div class="row"><span>Approve top of queue</span><kbd>A</kbd></div>
<div class="row"><span>Reject top of queue</span><kbd>R</kbd></div>
<div class="row"><span>Toggle alert sound</span><kbd>S</kbd></div>
<div class="row"><span>Focus add-symbol box</span><kbd>/</kbd></div>
<div class="row"><span>Show / hide this help</span><kbd>?</kbd></div>
<div class="row"><span>Close help</span><kbd>Esc</kbd></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const fmt = (v, dp = 2) => (v === null || v === undefined || v === "" || isNaN(+v)) ? "—" : (+v).toFixed(dp);
const money = (v) => (v === null || v === undefined || isNaN(+v)) ? "—" : (v < 0 ? "-$" : "$") + Math.abs(+v).toLocaleString("en-US", {minimumFractionDigits: 2, maximumFractionDigits: 2});
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const timeOf = (ts) => { try { return new Date(ts).toLocaleTimeString(); } catch { return "—"; } };
const signed = (el, v) => {
el.textContent = (v === null || v === undefined || isNaN(+v)) ? "—" : money(v);
el.className = "value num " + (+v > 0 ? "pos" : +v < 0 ? "neg" : "");
};
let latest = null;
let soundOn = false;
let lastSignalKey = "";
const expanded = new Set();
const barsCache = {};
let toastTimer;
function toast(msg, kind) {
const t = $("toast");
t.textContent = msg;
t.className = "toast show" + (kind ? " " + kind : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove("show"), 4000);
}
let audioCtx;
function beep() {
if (!soundOn) return;
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
[880, 1320].forEach((freq, i) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "sine"; osc.frequency.value = freq;
const t0 = audioCtx.currentTime + i * 0.12;
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(t0); osc.stop(t0 + 0.2);
});
} catch (e) {}
}
async function post(path, body) {
try {
const r = await fetch(path, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body) });
const data = await r.json().catch(() => ({}));
if (!r.ok || data.ok === false) toast(data.error || ("failed (" + r.status + ")"), "err");
else toast(data.status ? ("OK — " + data.status) : (data.symbol ? "Added " + data.symbol : "Done"), "ok");
} catch (e) { toast("network error: " + e.message, "err"); }
}
const approve = (id) => post("/api/trading/approvals/approve", {order_id: id, approved_by: "dashboard"});
const reject = (id) => post("/api/trading/approvals/reject", {order_id: id, reason: "dashboard_reject"});
const exitPos = (sym) => { if (confirm("Submit market exit for " + sym + "?")) post("/api/trading/exit-order", {symbol: sym}); };
function drawSpark(canvas, bars, entry, stop) {
const dpr = window.devicePixelRatio || 1;
const W = 132, H = 38;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
if (!bars || bars.length < 2) {
ctx.fillStyle = "#76809a"; ctx.font = "9px monospace";
ctx.fillText("no bars", 6, 22); return;
}
const closes = bars.map(b => b.c);
const levels = [entry, stop].filter(v => v != null && !isNaN(v));
const lo = Math.min.apply(null, closes.concat(levels));
const hi = Math.max.apply(null, closes.concat(levels));
const pad = (hi - lo) * 0.12 || 0.1;
const min = lo - pad, max = hi + pad;
const x = (i) => 2 + i * (W - 4) / (closes.length - 1);
const y = (v) => H - 3 - (v - min) / (max - min) * (H - 6);
const refLine = (v, color) => {
if (v == null || isNaN(v)) return;
ctx.strokeStyle = color; ctx.globalAlpha = 0.5; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]); ctx.beginPath();
ctx.moveTo(2, y(v)); ctx.lineTo(W - 2, y(v)); ctx.stroke();
ctx.setLineDash([]); ctx.globalAlpha = 1;
};
refLine(entry, "#4f9cf9");
refLine(stop, "#e5484d");
const up = closes[closes.length - 1] >= closes[0];
ctx.strokeStyle = up ? "#2fbf71" : "#e5484d";
ctx.lineWidth = 1.5; ctx.beginPath();
closes.forEach((c, i) => i ? ctx.lineTo(x(i), y(c)) : ctx.moveTo(x(i), y(c)));
ctx.stroke();
ctx.lineTo(x(closes.length - 1), H); ctx.lineTo(x(0), H); ctx.closePath();
ctx.globalAlpha = 0.10; ctx.fillStyle = up ? "#2fbf71" : "#e5484d";
ctx.fill(); ctx.globalAlpha = 1;
ctx.fillStyle = up ? "#2fbf71" : "#e5484d";
ctx.beginPath(); ctx.arc(x(closes.length - 1), y(closes[closes.length - 1]), 2, 0, 7); ctx.fill();
}
async function loadBars(symbol, entry, stop, canvas) {
const cached = barsCache[symbol];
if (cached && (Date.now() - cached.fetchedAt < 8000)) { drawSpark(canvas, cached.bars, entry, stop); return; }
try {
const r = await fetch("/api/bars?symbol=" + encodeURIComponent(symbol) + "&minutes=40");
const data = await r.json();
barsCache[symbol] = { bars: data.bars || [], fetchedAt: Date.now() };
drawSpark(canvas, data.bars || [], entry, stop);
} catch (e) { drawSpark(canvas, [], entry, stop); }
}
function riskChip(req) {
const qty = +req.quantity || 0, entry = +req.entry_price || 0, stop = +req.stop_loss_price || 0;
if (!qty || !entry || !stop) return "";
const perShare = Math.abs(entry - stop);
const dollarRisk = perShare * qty;
const notional = entry * qty;
return '<span class="risk">risk <b>' + money(dollarRisk) + '</b> &middot; ' + qty + 'sh &middot; notional <b>' + money(notional) + '</b> &middot; stop <b>' + (perShare/entry*100).toFixed(1) + '%</b></span>';
}
function table(headers, rows) {
if (!rows.length) return null;
return "<table><thead><tr>" + headers.map(h => "<th>" + h + "</th>").join("") + "</tr></thead><tbody>" + rows.join("") + "</tbody></table>";
}
function fill(id, html, emptyMsg) { $(id).innerHTML = html || '<div class="empty">' + emptyMsg + "</div>"; }
async function toggleCriteria(symbol) {
if (expanded.has(symbol)) expanded.delete(symbol); else expanded.add(symbol);
if (latest) renderWatch(latest.watch_states || []);
if (expanded.has(symbol)) {
try {
const r = await fetch("/api/criteria?symbol=" + encodeURIComponent(symbol));
const host = document.querySelector('[data-crit="' + CSS.escape(symbol) + '"]');
if (host) host.innerHTML = renderCriteriaGrid(await r.json());
} catch (e) {}
}
}
function renderCriteriaGrid(data) {
if (!data.criteria || !data.criteria.length)
return '<div class="empty" style="padding:12px 16px">No criteria evaluated yet.</div>';
const cells = data.criteria.map(c => {
const cls = c.passed === true ? "pass" : c.passed === false ? "fail" : "skip";
const mark = c.passed === true ? "✓" : c.passed === false ? "✕" : "·";
return '<div class="crit ' + cls + '"><span class="mark">' + mark + '</span>' + esc(c.label) + '</div>';
}).join("");
const score = data.score != null ? 'score ' + (+data.score).toFixed(0) + '% &middot; ' + data.passed_count + '/' + data.total_count + ' &middot; ' + esc(data.status||"") : "";
return '<div class="criteria-grid">' + cells + '</div><div class="spark-meta" style="padding:0 16px 10px">' + score + '</div>';
}
function renderWatch(ws) {
$("ws-count").textContent = ws.length;
if (!ws.length) { fill("watch-states", null, "Watcher has not evaluated any symbols yet."); return; }
const rows = ws.map(w => {
const isOpen = expanded.has(w.symbol);
const main = '<tr class="expandable" onclick="toggleCriteria(\'' + esc(w.symbol) + '\')">' +
'<td><span class="caret ' + (isOpen ? "open" : "") + '">▶</span> <span class="sym">' + esc(w.symbol) + '</span></td>' +
'<td><span class="pill ' + esc(w.state || "discovered") + '">' + esc(w.state || "—") + '</span></td>' +
'<td class="num">' + fmt(w.last_score, 0) + (w.last_score != null ? "%" : "") + '</td>' +
'<td style="color:var(--muted)">' + esc(w.state_reason || "") + '</td>' +
'<td class="num">' + timeOf(w.last_transition_at || w.last_criteria_at) + '</td></tr>';
const detail = isOpen
? '<tr><td class="criteria-cell" colspan="5"><div data-crit="' + esc(w.symbol) + '"><div class="empty" style="padding:12px 16px">loading...</div></div></td></tr>'
: "";
return main + detail;
});
fill("watch-states", table(["Symbol", "State", "Score", "Reason", "Updated"], rows), "");
expanded.forEach(async (symbol) => {
const host = document.querySelector('[data-crit="' + CSS.escape(symbol) + '"]');
if (host && host.querySelector(".empty")) {
try { host.innerHTML = renderCriteriaGrid(await (await fetch("/api/criteria?symbol=" + encodeURIComponent(symbol))).json()); }
catch (e) {}
}
});
}
function render(d) {
latest = d;
const live = d.execution_mode === "schwab_live";
const badge = $("mode-badge");
badge.textContent = live ? "LIVE · SCHWAB" : "PAPER · ALPACA";
badge.className = "badge " + (live ? "live" : "paper");
const p = d.pnl || {};
signed($("pnl-total"), p.total_pnl);
signed($("pnl-realized"), p.realized_pnl);
signed($("pnl-unrealized"), p.unrealized_pnl);
$("pnl-winrate").textContent = p.win_rate != null ? (p.win_rate * 100).toFixed(0) + "%" : "—";
$("pnl-winrate").className = "value num";
$("pnl-avgr").textContent = p.avg_r_multiple != null ? p.avg_r_multiple.toFixed(2) + "R" : "—";
$("pnl-avgr").className = "value num " + (p.avg_r_multiple > 0 ? "pos" : p.avg_r_multiple < 0 ? "neg" : "");
$("pnl-counts").textContent = (p.open_positions != null ? p.open_positions : 0) + " / " + (p.closed_trades != null ? p.closed_trades : 0);
$("pnl-counts").className = "value num";
const accts = d.accounts || [];
$("acct-count").textContent = accts.length;
if (accts.length) {
$("accounts").innerHTML = accts.map(a =>
'<div class="metric"><div class="label">' + esc(a.broker_name) + ' &middot; ' + esc(a.account_id) + '</div><div class="value">' + money(a.total_equity != null ? a.total_equity : a.net_liquidating_value) + '</div></div>' +
'<div class="metric"><div class="label">Cash</div><div class="value">' + money(a.cash_balance) + '</div></div>' +
'<div class="metric"><div class="label">Buying Power</div><div class="value">' + money(a.buying_power) + '</div></div>' +
'<div class="metric"><div class="label">Synced</div><div class="value" style="font-size:13px">' + timeOf(a.timestamp) + '</div></div>'
).join("");
} else {
$("accounts").innerHTML = '<span class="empty">No account snapshot yet — waiting for broker sync.</span>';
}
const aq = d.approval_queue || [];
$("aq-count").textContent = aq.length;
fill("approval-queue", table(
["Symbol", "Side", "Risk preview", "Mode", "Actions"],
aq.map(q => {
const r = q.execution_request || {};
return '<tr><td class="sym">' + esc(q.symbol) + '</td>' +
'<td><span class="pill ' + (r.side === "sell" ? "short" : "long") + '">' + esc(r.side || "buy") + '</span></td>' +
'<td>' + riskChip(r) + '</td>' +
'<td class="num">' + esc(q.approval_mode || "manual") + '</td>' +
'<td><div class="actions">' +
'<button class="btn-approve" onclick="approve(\'' + esc(q.order_id) + '\')">Approve</button>' +
'<button class="btn-reject" onclick="reject(\'' + esc(q.order_id) + '\')">Reject</button>' +
'</div></td></tr>';
})
), "No pending approvals.");
const rs = d.ready_signals || [];
$("rs-count").textContent = rs.length;
fill("ready-signals", table(
["Symbol", "Chart", "Setup", "Entry", "Stop", "Qual", "Time"],
rs.map(s => '<tr><td class="sym">' + esc(s.symbol) + '</td>' +
'<td class="spark-wrap"><canvas class="spark" data-spark="' + esc(s.symbol) + '" data-entry="' + (s.entry_price != null ? s.entry_price : "") + '" data-stop="' + (s.stop_loss_price != null ? s.stop_loss_price : "") + '"></canvas></td>' +
'<td>' + esc(s.signal_type || "—") + '</td>' +
'<td class="num">' + fmt(s.entry_price) + '</td>' +
'<td class="num">' + fmt(s.stop_loss_price) + '</td>' +
'<td class="num">' + fmt(s.quality_score) + '</td>' +
'<td class="num">' + timeOf(s.timestamp) + '</td></tr>')
), "No ready signals this session.");
const posRows = [];
(d.positions || []).forEach(snap => (snap.positions || []).forEach(pos => {
const qty = +(pos.quantity != null ? pos.quantity : pos.qty != null ? pos.qty : 0);
posRows.push('<tr><td class="sym">' + esc(pos.symbol) + '</td>' +
'<td class="spark-wrap"><canvas class="spark" data-spark="' + esc(pos.symbol) + '" data-entry="' + (pos.avg_entry_price != null ? pos.avg_entry_price : pos.average_price != null ? pos.average_price : "") + '" data-stop=""></canvas></td>' +
'<td><span class="pill ' + (qty < 0 ? "short" : "long") + '">' + (qty < 0 ? "short" : "long") + '</span></td>' +
'<td class="num">' + esc(Math.abs(qty)) + '</td>' +
'<td class="num">' + fmt(pos.avg_entry_price != null ? pos.avg_entry_price : pos.average_price) + '</td>' +
'<td class="num">' + fmt(pos.current_price != null ? pos.current_price : pos.market_price) + '</td>' +
'<td class="num ' + (+(pos.unrealized_pl || 0) >= 0 ? "pos" : "neg") + '">' + money(pos.unrealized_pl) + '</td>' +
'<td><button class="btn-exit" onclick="exitPos(\'' + esc(pos.symbol) + '\')">Exit</button></td></tr>');
}));
$("pos-count").textContent = posRows.length;
fill("positions", table(["Symbol", "Chart", "Side", "Qty", "Avg", "Last", "P/L", ""], posRows), "No open positions.");
renderWatch(d.watch_states || []);
const fills = d.fills || [];
$("feed-count").textContent = fills.length;
fill("feed", fills.length ? fills.map(f => {
const color = f.kind === "order_filled" ? "var(--green)" : "var(--accent)";
const verb = f.kind === "order_filled" ? "FILL" : "SENT";
return '<div class="feed-row"><span class="feed-dot" style="background:' + color + '"></span>' +
'<span class="feed-time">' + timeOf(f.timestamp) + '</span>' +
'<span style="color:' + color + ';font-weight:600">' + verb + '</span>' +
'<span class="sym">' + esc(f.symbol || "") + '</span>' +
'<span class="pill ' + (f.side === "sell" ? "short" : "long") + '">' + esc(f.side || "") + '</span>' +
'<span>' + esc(f.quantity != null ? f.quantity : "") + ' @ ' + fmt(f.price) + '</span></div>';
}).join("") : null, "No fills yet this session.");
const ol = d.order_lifecycle || [];
$("ol-count").textContent = ol.length;
fill("order-lifecycle", table(
["Order", "Symbol", "Side", "Qty", "Price", "Status", "Updated"],
ol.slice(0, 30).map(o => '<tr><td class="num" style="color:var(--muted)">' + esc(String(o.order_id || "").slice(0, 8)) + '</td>' +
'<td class="sym">' + esc(o.symbol) + '</td>' +
'<td><span class="pill ' + (o.side === "sell" ? "short" : "long") + '">' + esc(o.side || "—") + '</span></td>' +
'<td class="num">' + esc(o.quantity != null ? o.quantity : "—") + '</td>' +
'<td class="num">' + fmt(o.price) + '</td>' +
'<td class="num">' + esc(o.status || "—") + '</td>' +
'<td class="num">' + timeOf(o.updated_at) + '</td></tr>')
), "No orders yet.");
document.querySelectorAll("canvas[data-spark]").forEach(canvas => {
const sym = canvas.getAttribute("data-spark");
const entry = parseFloat(canvas.getAttribute("data-entry"));
const stop = parseFloat(canvas.getAttribute("data-stop"));
loadBars(sym, isNaN(entry) ? null : entry, isNaN(stop) ? null : stop, canvas);
});
const key = rs.length ? rs[0].symbol + "|" + rs[0].timestamp : "";
if (key && key !== lastSignalKey && lastSignalKey !== "") {
beep();
toast("New ready signal: " + rs[0].symbol + " (" + (rs[0].signal_type || "setup") + ")", "ok");
}
lastSignalKey = key;
}
let es = null, pollTimer = null;
function setConn(ok, msg) {
$("conn-dot").className = "dot " + (ok ? "live" : "err");
$("stamp").textContent = msg;
}
function startStream() {
try {
es = new EventSource("/api/stream");
es.addEventListener("snapshot", (ev) => {
try { render(JSON.parse(ev.data)); setConn(true, "live · " + new Date().toLocaleTimeString()); }
catch (e) {}
});
es.onerror = () => { setConn(false, "stream lost — polling"); es.close(); es = null; startPolling(); };
} catch (e) { startPolling(); }
}
async function pollOnce() {
try {
const r = await fetch("/api/snapshots");
if (!r.ok) throw new Error("HTTP " + r.status);
render(await r.json());
setConn(true, "polling · " + new Date().toLocaleTimeString());
} catch (e) { setConn(false, "disconnected — " + e.message); }
}
function startPolling() {
if (pollTimer) return;
pollOnce();
pollTimer = setInterval(pollOnce, 4000);
}
$("sound-toggle").onclick = () => {
soundOn = !soundOn;
$("sound-toggle").textContent = soundOn ? "SOUND ON" : "SOUND OFF";
$("sound-toggle").className = "hdr-btn" + (soundOn ? " on" : "");
if (soundOn) beep();
};
$("kbd-toggle").onclick = () => $("kbd-help").classList.toggle("show");
$("kbd-help").onclick = (e) => { if (e.target.id === "kbd-help") $("kbd-help").classList.remove("show"); };
function doInject() {
const v = $("inject-input").value.trim().toUpperCase();
if (v) { post("/api/watch/add", {symbol: v}); $("inject-input").value = ""; }
}
$("inject-btn").onclick = doInject;
$("inject-input").addEventListener("keydown", (e) => { if (e.key === "Enter") doInject(); e.stopPropagation(); });
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
const aq = latest && latest.approval_queue || [];
if (e.key === "a" || e.key === "A") { if (aq[0]) approve(aq[0].order_id); }
else if (e.key === "r" || e.key === "R") { if (aq[0]) reject(aq[0].order_id); }
else if (e.key === "s" || e.key === "S") { $("sound-toggle").click(); }
else if (e.key === "/") { e.preventDefault(); $("inject-input").focus(); }
else if (e.key === "?") { $("kbd-help").classList.toggle("show"); }
else if (e.key === "Escape") { $("kbd-help").classList.remove("show"); }
});
startStream();
setTimeout(() => { if (!latest) startPolling(); }, 3000);
</script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
"""Run intraday backtests against bars stored in the research database.
python backtest_cli.py --symbol SNDL --date 2026-06-10
python backtest_cli.py --date 2026-06-10 # all in-band symbols
"""
from __future__ import annotations
import argparse
import sys
from datetime import date
from research import query as rq
from research.multi_schema import open_research_db
from strategy.backtest.engine import BacktestEngine
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--symbol", help="single symbol (default: scan session)")
parser.add_argument("--date", required=True, help="session date YYYY-MM-DD")
parser.add_argument("--price-min", type=float, default=1.0)
parser.add_argument("--price-max", type=float, default=20.0)
parser.add_argument("--equity", type=float, default=100_000.0)
args = parser.parse_args(argv)
session_date = date.fromisoformat(args.date)
con = open_research_db("market")
engine = BacktestEngine(equity=args.equity)
if args.symbol:
symbols = [args.symbol.upper()]
else:
rows = rq.query_session_symbols(
con, session_date, price_min=args.price_min, price_max=args.price_max
)
symbols = [r["symbol"] for r in rows]
if not symbols:
print(f"no symbols with minute bars on {session_date} in "
f"[{args.price_min}, {args.price_max}] — run fetch_minute_bars.py first")
return 1
total_pnl = 0.0
total_trades = 0
for symbol in symbols:
bars = rq.query_minute_bars(con, symbol, session_date)
if bars.empty:
print(f"{symbol}: no bars")
continue
result = engine.run(
bars,
symbol,
previous_close=rq.query_previous_close(con, symbol, session_date),
avg_daily_volume=rq.query_avg_daily_volume(con, symbol, session_date),
)
s = result.summary()
total_pnl += s["total_pnl"]
total_trades += s["trades"]
print(f"{symbol:>6} trades={s['trades']:<3} win_rate={s['win_rate']:<6} "
f"pnl={s['total_pnl']:>9.2f} signals={s['signals']} evals={s['evaluations']}")
print(f"\nTOTAL trades={total_trades} pnl={total_pnl:.2f}")
con.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""Multi-week comprehensive backtest with detailed statistical analysis."""
from datetime import datetime, timedelta, date
import statistics
import pandas as pd
from rich.console import Console
from rich.table import Table
from config import Config
from research.connection import ResearchConnection
from research.query import query_minute_bars
from strategy.backtest.engine import BacktestEngine
console = Console()
def get_trading_days(start: date, end: date) -> list[date]:
"""Get all weekdays between start and end (trading days only)."""
days = []
current = start
while current <= end:
# Only include weekdays (0=Monday, 6=Sunday)
if current.weekday() < 5:
days.append(current)
current += timedelta(days=1)
return days
def run_backtest_multi_day(start_date: date, end_date: date, symbols: list[str] | None = None):
"""Run backtest across multiple days and aggregate results."""
config = Config()
con = ResearchConnection()
if symbols is None:
symbols = ["SNDL", "MARA", "RIOT", "BBAI", "SOUN", "PLUG"]
console.print(f"\n[bold blue]Comprehensive Backtest: {start_date} to {end_date}[/bold blue]")
console.print(f"[dim]Testing {len(symbols)} symbols across trading days[/dim]\n")
trading_days = get_trading_days(start_date, end_date)
console.print(f"[cyan]{len(trading_days)} trading days in range[/cyan]\n")
# Aggregate results
all_trades: list[dict] = []
daily_results: dict[date, dict] = {}
symbol_stats: dict[str, dict] = {}
engine = BacktestEngine(config)
# Progress bar
processed = 0
for trading_day in trading_days:
# Query minute bars for all symbols that day
day_symbols_with_data = []
day_bars = {}
for symbol in symbols:
try:
bars = query_minute_bars(con, symbol, trading_day)
if len(bars) > 0:
day_symbols_with_data.append(symbol)
day_bars[symbol] = bars
except Exception as e:
pass # No data for this day
if not day_symbols_with_data:
processed += 1
continue
# Initialize daily results if needed
if trading_day not in daily_results:
daily_results[trading_day] = {"trades": [], "pnl": 0.0}
# Run backtest for each symbol with data
for symbol in day_symbols_with_data:
bars = day_bars[symbol]
try:
result = engine.run(bars, symbol)
# Aggregate
daily_results[trading_day]["pnl"] += result.total_pnl
# Initialize symbol stats if needed
if symbol not in symbol_stats:
symbol_stats[symbol] = {"trades": [], "wins": 0, "losses": 0, "pnl": 0.0}
for trade in result.trades:
trade_data = {
"date": trading_day.isoformat(),
"symbol": symbol,
"entry_time": trade.entry_time,
"entry_price": trade.entry_price,
"stop_price": trade.stop_price,
"target_price": trade.target_price,
"quantity": trade.quantity,
"exit_price": trade.exit_price,
"exit_reason": trade.exit_reason,
"pnl": trade.realized_pnl or 0.0,
"r_multiple": trade.r_multiple or 0.0,
}
all_trades.append(trade_data)
daily_results[trading_day]["trades"].append(trade_data)
# Symbol stats
symbol_stats[symbol]["trades"].append(trade_data)
symbol_stats[symbol]["pnl"] += trade.realized_pnl or 0.0
if (trade.realized_pnl or 0) > 0:
symbol_stats[symbol]["wins"] += 1
else:
symbol_stats[symbol]["losses"] += 1
except Exception as e:
console.print(f"[red]Error backtesting {symbol} on {trading_day}: {e}[/red]")
processed += 1
console.print(f"[dim]Processed {processed}/{len(trading_days)} days[/dim]", end="\r")
console.print()
# === SUMMARY STATISTICS ===
if not all_trades:
console.print("[yellow]No trades found in backtest range[/yellow]")
return
# Daily summary
console.print("\n[bold cyan]=== DAILY SUMMARY ===[/bold cyan]")
daily_table = Table(title="P&L by Day")
daily_table.add_column("Date", style="cyan")
daily_table.add_column("Trades", justify="right")
daily_table.add_column("P&L", justify="right")
daily_pnls = []
for day in sorted(daily_results.keys()):
daily_pnl = daily_results[day]["pnl"]
daily_pnls.append(daily_pnl)
color = "green" if daily_pnl > 0 else "red"
daily_table.add_row(
str(day),
str(len(daily_results[day]["trades"])),
f"[{color}]${daily_pnl:,.2f}[/{color}]"
)
console.print(daily_table)
# Symbol summary
console.print("\n[bold cyan]=== SYMBOL SUMMARY ===[/bold cyan]")
symbol_table = Table(title="Results by Symbol")
symbol_table.add_column("Symbol", style="cyan")
symbol_table.add_column("Trades", justify="right")
symbol_table.add_column("Wins", justify="right")
symbol_table.add_column("Win %", justify="right")
symbol_table.add_column("P&L", justify="right")
symbol_table.add_column("Avg R", justify="right")
for symbol in sorted(symbol_stats.keys()):
stats = symbol_stats[symbol]
trades = stats["trades"]
if not trades:
continue
win_pct = (stats["wins"] / len(trades)) * 100 if trades else 0
avg_r = statistics.mean([t["r_multiple"] for t in trades]) if trades else 0
color = "green" if stats["pnl"] > 0 else "red"
symbol_table.add_row(
symbol,
str(len(trades)),
str(stats["wins"]),
f"{win_pct:.1f}%",
f"[{color}]${stats['pnl']:,.2f}[/{color}]",
f"{avg_r:.2f}R"
)
console.print(symbol_table)
# Overall stats
total_trades = len(all_trades)
total_pnl = sum(t["pnl"] for t in all_trades)
wins = len([t for t in all_trades if t["pnl"] > 0])
losses = len([t for t in all_trades if t["pnl"] < 0])
win_rate = (wins / total_trades) * 100 if total_trades else 0
r_multiples = [t["r_multiple"] for t in all_trades]
avg_r = statistics.mean(r_multiples) if r_multiples else 0
# Separate wins and losses for analysis
win_trades = [t for t in all_trades if t["pnl"] > 0]
loss_trades = [t for t in all_trades if t["pnl"] < 0]
avg_win = statistics.mean([t["pnl"] for t in win_trades]) if win_trades else 0
avg_loss = statistics.mean([t["pnl"] for t in loss_trades]) if loss_trades else 0
max_loss = min([t["pnl"] for t in loss_trades]) if loss_trades else 0
profit_factor = (sum([t["pnl"] for t in win_trades]) / abs(sum([t["pnl"] for t in loss_trades]))) if loss_trades and sum([t["pnl"] for t in loss_trades]) < 0 else 0
console.print("\n[bold cyan]=== OVERALL STATISTICS ===[/bold cyan]")
console.print(f"Total Trades: {total_trades}")
console.print(f"Wins: {wins} | Losses: {losses}")
console.print(f"Win Rate: {win_rate:.1f}%")
console.print(f"Total P&L: [{'green' if total_pnl > 0 else 'red'}]${total_pnl:,.2f}[/{'green' if total_pnl > 0 else 'red'}]")
console.print(f"Avg Win: ${avg_win:,.2f} | Avg Loss: ${avg_loss:,.2f}")
console.print(f"Max Loss: ${max_loss:,.2f}")
if profit_factor > 0:
console.print(f"Profit Factor: {profit_factor:.2f}x")
console.print(f"Avg R-Multiple: {avg_r:.2f}R")
# === LOSS ANALYSIS ===
if loss_trades:
console.print("\n[bold yellow]=== LOSS ANALYSIS ===[/bold yellow]")
console.print(f"Total Losing Trades: {len(loss_trades)}")
loss_table = Table(title="Top 10 Losses (for pattern analysis)")
loss_table.add_column("Date", style="cyan")
loss_table.add_column("Symbol", style="cyan")
loss_table.add_column("Entry", justify="right")
loss_table.add_column("Stop", justify="right")
loss_table.add_column("Exit", justify="right")
loss_table.add_column("P&L", justify="right")
loss_table.add_column("Reason")
sorted_losses = sorted(loss_trades, key=lambda t: t["pnl"])
for trade in sorted_losses[:10]:
loss_table.add_row(
trade["date"],
trade["symbol"],
f"${trade['entry_price']:.2f}",
f"${trade['stop_price']:.2f}",
f"${trade['exit_price']:.2f}" if trade["exit_price"] else "-",
f"[red]${trade['pnl']:,.2f}[/red]",
trade["exit_reason"] or "?"
)
console.print(loss_table)
# Loss metrics
stop_hits = len([t for t in loss_trades if "stop" in (t["exit_reason"] or "").lower()])
console.print(f"\n[dim]Losses by reason:[/dim]")
console.print(f" Hit stop: {stop_hits}/{len(loss_trades)}")
# Gap-through analysis
for trade in sorted_losses[:5]:
entry = trade["entry_price"]
stop = trade["stop_price"]
exit_price = trade["exit_price"]
if exit_price and exit_price < stop:
gap_pct = ((stop - exit_price) / stop) * 100
console.print(f" [yellow]{trade['symbol']} {trade['date']}: gap-through {gap_pct:.2f}% ({stop:.2f} → {exit_price:.2f})[/yellow]")
# === WIN ANALYSIS ===
if win_trades:
console.print("\n[bold green]=== WIN ANALYSIS ===[/bold green]")
console.print(f"Total Winning Trades: {len(win_trades)}")
win_table = Table(title="Recent Wins")
win_table.add_column("Date", style="cyan")
win_table.add_column("Symbol", style="cyan")
win_table.add_column("Entry", justify="right")
win_table.add_column("Target", justify="right")
win_table.add_column("Exit", justify="right")
win_table.add_column("P&L", justify="right")
win_table.add_column("R")
sorted_wins = sorted(win_trades, key=lambda t: t["pnl"], reverse=True)
for trade in sorted_wins[:5]:
win_table.add_row(
trade["date"],
trade["symbol"],
f"${trade['entry_price']:.2f}",
f"${trade['target_price']:.2f}",
f"${trade['exit_price']:.2f}" if trade["exit_price"] else "-",
f"[green]${trade['pnl']:,.2f}[/green]",
f"{trade['r_multiple']:.2f}R"
)
console.print(win_table)
# === RISK ASSESSMENT ===
console.print("\n[bold yellow]=== RISK ASSESSMENT ===[/bold yellow]")
if avg_loss != 0:
expectancy = (win_rate / 100 * avg_win) + ((1 - win_rate / 100) * avg_loss)
console.print(f"Expected value per trade: ${expectancy:,.2f}")
if profit_factor > 0:
if profit_factor < 1.5:
console.print("[yellow]⚠️ Profit factor < 1.5 indicates marginal edge or slippage issues[/yellow]")
else:
console.print("[green]✓ Profit factor suggests adequate win/loss ratio[/green]")
# Check for consistent losses
max_win = max([t["pnl"] for t in win_trades]) if win_trades else 0
if max_loss < 0 and abs(max_loss) > max_win * 1.5:
console.print(f"[red]⚠️ Max loss (${max_loss:,.2f}) >> max win (${max_win:,.2f}) — stop is too wide or slippage is severe[/red]")
# Sharpe-ish ratio (simplified)
if len(daily_pnls) > 1:
daily_std = statistics.stdev(daily_pnls) if len(daily_pnls) > 1 else 0
if daily_std > 0:
daily_mean = statistics.mean(daily_pnls)
sharpe_approx = daily_mean / daily_std if daily_std > 0 else 0
console.print(f"Daily Sharpe (approx): {sharpe_approx:.2f}")
console.print("\n[dim]Recommendations:[/dim]")
if win_rate < 40:
console.print("[red]• Win rate < 40% — strategy may lack edge even with profitable trades[/red]")
if profit_factor < 1.3 and profit_factor > 0:
console.print("[yellow]• Profit factor < 1.3 — consider tightening stops or improving entry[/yellow]")
if len(loss_trades) > 0 and max_loss < avg_loss / 2:
console.print("[yellow]• Losses are inconsistent — gap-throughs may be a factor[/yellow]")
console.print()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Multi-week comprehensive backtest")
parser.add_argument("--start", type=str, default="2026-06-05", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end", type=str, default="2026-06-12", help="End date (YYYY-MM-DD)")
parser.add_argument("--symbols", type=str, default="SNDL,MARA,RIOT,BBAI,SOUN,PLUG", help="Comma-separated symbols")
args = parser.parse_args()
start = datetime.strptime(args.start, "%Y-%m-%d").date()
end = datetime.strptime(args.end, "%Y-%m-%d").date()
symbols = args.symbols.split(",")
run_backtest_multi_day(start, end, symbols)
+191
View File
@@ -0,0 +1,191 @@
"""Configuration loading from YAML files."""
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict
class SlippageConfig(BaseModel):
base_spread_pct: float = 0.0015
volatility_adder_pct: float = 0.0005
atr_volatility_threshold: float = 2.0
class CommissionConfig(BaseModel):
per_share: float = 0.005
minimum: float = 1.00
class BacktestConfig(BaseModel):
slippage: SlippageConfig = SlippageConfig()
commission: CommissionConfig = CommissionConfig()
entry_mode: str = "next_bar"
class ScannerWeights(BaseModel):
gap_strength: float = 0.30
relative_volume: float = 0.25
premarket_participation: float = 0.20
range_expansion: float = 0.15
catalyst_freshness: float = 0.10
class ScannerConfig(BaseModel):
gap_pct_min: float = 0.05
premarket_volume_min: int = 250_000
relative_volume_min: float = 2.5
spread_pct_max: float = 0.05
quality_score_min: float = 0.7
weights: ScannerWeights = ScannerWeights()
class SetupConfig(BaseModel):
impulse_size_min: float = 0.05
max_pullback_depth_pct: float = 0.40
breakout_volume_ratio_min: float = 1.5
pullback_volume_ratio_max: float = 0.6
timeframes: list[int] = [1, 5]
class RiskConfig(BaseModel):
risk_per_trade_pct: float = 0.01
max_daily_loss_pct: float = 0.03
max_concurrent_positions: int = 3
no_new_entries_after: str = "10:30"
no_averaging_down: bool = True
class UniverseConfig(BaseModel):
price_min: float = 1.0
price_max: float = 20.0
min_avg_volume_20d: int = 500_000
exclude_etf: bool = True
exclude_otc: bool = True
common_shares_only: bool = True
class QualityConfig(BaseModel):
min_regular_hours_coverage: float = 0.95
min_premarket_coverage: float = 0.80
max_price_gap_pct: float = 0.20
min_score: float = 0.7
class DataConfig(BaseModel):
dir: str = "./data"
db_path: str = "./data/momentum.duckdb"
class TelemetryConfig(BaseModel):
heartbeat_interval_seconds: int = 60
log_dir: str = "./data/logs"
class ProviderQuotaConfig(BaseModel):
"""Per-provider API quota and rate limit settings."""
model_config = ConfigDict(extra="ignore")
fmp_daily_limit: int = 250
fmp_intraday_limit: int = 100
twelve_data_daily_limit: int = 800
twelve_data_intraday_limit: int = 120
alpha_vantage_daily_limit: int = 5
newsapi_daily_limit: int = 100
newsdata_daily_limit: int = 200
marketaux_daily_limit: int = 100
mediastack_daily_limit: int = 100
class RSSFeedConfig(BaseModel):
"""RSS feed source definitions."""
model_config = ConfigDict(extra="ignore")
feeds: list[str] = [
"https://feeds.bloomberg.com/markets/news.rss",
"https://feeds.marketwatch.com/marketwatch/topstories/",
"https://www.cnbc.com/id/100003114/device/rss/rss.html",
"https://finance.yahoo.com/news/rssindex",
"https://seekingalpha.com/feed.xml",
]
class NewsSourcesConfig(BaseModel):
"""News source configuration and cadence."""
model_config = ConfigDict(extra="ignore")
finnhub_enabled: bool = True
finnhub_refresh_interval_seconds: int = 300
rss_enabled: bool = True
rss_refresh_interval_seconds: int = 600
sec_enabled: bool = True
sec_refresh_interval_seconds: int = 3600
fed_enabled: bool = True
fed_refresh_interval_seconds: int = 3600
newsapi_enabled: bool = False
newsapi_refresh_interval_seconds: int = 600
newsdata_enabled: bool = False
newsdata_refresh_interval_seconds: int = 600
marketaux_enabled: bool = False
marketaux_refresh_interval_seconds: int = 600
mediastack_enabled: bool = False
mediastack_refresh_interval_seconds: int = 600
source_cooldown_default_seconds: int = 180
provider_source_cooldown_seconds: int = 180
rss_source_cooldown_seconds: int = 180
regulatory_source_cooldown_seconds: int = 180
rss_feeds: RSSFeedConfig = RSSFeedConfig()
class OllamaConfig(BaseModel):
"""Ollama local LLM settings for enrichment."""
model_config = ConfigDict(extra="ignore")
enabled: bool = False
host: str = "http://localhost:11434"
model: str = "mistral"
timeout_seconds: int = 30
max_tokens: int = 256
temperature: float = 0.3
class CapabilityRegistryConfig(BaseModel):
"""Capability registry configuration."""
model_config = ConfigDict(extra="ignore")
enabled: bool = True
class Config(BaseModel):
model_config = ConfigDict(extra="ignore")
data: DataConfig = DataConfig()
universe: UniverseConfig = UniverseConfig()
scanner: ScannerConfig = ScannerConfig()
setup: SetupConfig = SetupConfig()
risk: RiskConfig = RiskConfig()
backtest: BacktestConfig = BacktestConfig()
quality: QualityConfig = QualityConfig()
telemetry: TelemetryConfig = TelemetryConfig()
providers: ProviderQuotaConfig = ProviderQuotaConfig()
news_sources: NewsSourcesConfig = NewsSourcesConfig()
ollama: OllamaConfig = OllamaConfig()
capability_registry: CapabilityRegistryConfig = CapabilityRegistryConfig()
def load_config(path: str | Path | None = None) -> Config:
"""Load config from YAML file, falling back to defaults."""
if path is None:
path = Path("configs/base.yaml")
path = Path(path)
if path.exists():
with open(path) as f:
raw: dict[str, Any] = yaml.safe_load(f) or {}
return Config(**raw)
return Config()
View File
Whitespace-only changes.
+259
View File
@@ -0,0 +1,259 @@
"""Module contracts and interfaces for the momentum trading system.
Every module must implement the ModuleContract protocol, defining:
- input_schema: What the module expects
- output_schema: What the module produces
- run(): Execute the module
- validate_input(): Ensure data meets contracts
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, date
from typing import Any, Protocol, TypeVar
T_Input = TypeVar("T_Input")
T_Output = TypeVar("T_Output")
@dataclass
class ModuleMetrics:
"""Standard metrics every module should track."""
module_name: str
timestamp: datetime
duration_ms: float
input_count: int = 0
output_count: int = 0
error_count: int = 0
warnings: list[str] = field(default_factory=list)
custom_metrics: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"module_name": self.module_name,
"timestamp": self.timestamp.isoformat(),
"duration_ms": self.duration_ms,
"input_count": self.input_count,
"output_count": self.output_count,
"error_count": self.error_count,
"warnings": self.warnings,
"custom_metrics": self.custom_metrics,
}
@dataclass
class ValidationFailure:
"""Single validation failure with context."""
field: str
value: Any
reason: str
severity: str = "error" # "error" or "warning"
@dataclass
class ModuleResult:
"""Base class for all module results."""
success: bool
timestamp: datetime
metrics: ModuleMetrics
validation_failures: list[ValidationFailure] = field(default_factory=list)
def has_errors(self) -> bool:
return bool(self.validation_failures or not self.success)
def error_summary(self) -> str:
"""Human-readable error summary."""
if not self.validation_failures:
return "No errors"
errors = [f for f in self.validation_failures if f.severity == "error"]
if not errors:
return f"{len(self.validation_failures)} warnings only"
return f"{len(errors)} error(s), {len([f for f in self.validation_failures if f.severity == 'warning'])} warning(s)"
class ModuleContract(Protocol):
"""Contract every module must fulfill."""
def run(self, input: Any) -> ModuleResult:
"""Execute the module.
Args:
input: Module-specific input conforming to input_schema
Returns:
Module-specific result inheriting from ModuleResult
"""
...
def validate_input(self, input: Any) -> tuple[bool, list[ValidationFailure]]:
"""Validate input before processing.
Args:
input: Data to validate
Returns:
(is_valid, failures)
"""
...
def get_metrics(self) -> ModuleMetrics:
"""Get last execution metrics."""
...
# ============================================================================
# INGESTION MODULE CONTRACTS
# ============================================================================
@dataclass
class BarIngestRequest:
"""Input contract for ingestion module."""
symbols: list[str]
session_date: date
lookback_minutes: int = 120 # how many minutes of history to fetch
source: str = "alpaca" # "alpaca", "test", "file"
@dataclass
class BarIngestResult(ModuleResult):
"""Output contract for ingestion module."""
minute_rows_inserted: int = 0
symbols_updated: list[str] = field(default_factory=list)
source_health: dict[str, str] = field(default_factory=dict) # {source: "healthy"|"error"}
# ============================================================================
# RESEARCH MODULE CONTRACTS
# ============================================================================
@dataclass
class ResearchRequest:
"""Input contract for research module."""
symbols: list[str]
session_date: date
lookback_days: int = 30 # for daily history
@dataclass
class ResearchResult(ModuleResult):
"""Output contract for research module."""
symbols_researched: list[str] = field(default_factory=list)
symbols_with_data: list[str] = field(default_factory=list)
symbols_missing_data: list[str] = field(default_factory=list)
total_bars_available: int = 0
# ============================================================================
# EVALUATION MODULE CONTRACTS
# ============================================================================
@dataclass
class CriterionDetail:
"""Result of evaluating one criterion."""
name: str
passed: bool
value: float # actual metric value
threshold: float | None = None # minimum to pass
reason: str = ""
confidence: float = 1.0 # 0-1, how confident in this result
@dataclass
class EvaluationRequest:
"""Input contract for evaluation module."""
symbol: str
bars_df: Any # pd.DataFrame with {timestamp, open, high, low, close, volume}
previous_close: float
avg_daily_volume: float
session_date: date
@dataclass
class EvaluationResult(ModuleResult):
"""Output contract for evaluation module."""
symbol: str = ""
status: str = "late" # "ready", "blocked", "late", "error"
score: float = 0.0 # 0-100
criteria: list[CriterionDetail] = field(default_factory=list)
entry_price: float | None = None
stop_loss: float | None = None
target_price: float | None = None
quality_score: float = 0.0 # 0-1
blocking_reason: str | None = None
metrics_dict: dict[str, float] = field(default_factory=dict) # gap%, rvol, etc.
# ============================================================================
# EXECUTION MODULE CONTRACTS
# ============================================================================
@dataclass
class ExecutionRequest:
"""Input contract for execution module."""
symbol: str
entry_price: float
stop_price: float
target_price: float
score: float
criteria_count: int
account_equity: float
current_positions: int
today_realized_pnl: float
@dataclass
class ExecutionResult(ModuleResult):
"""Output contract for execution module."""
symbol: str = ""
status: str = "" # "approved", "rejected", "pending_approval", "error"
reason: str = "" # why it was approved/rejected
order_id: str | None = None
shares: int | None = None
risk_dollars: float | None = None
risk_checks_applied: list[str] = field(default_factory=list)
# ============================================================================
# BROKER MODULE CONTRACTS
# ============================================================================
@dataclass
class BrokerRequest:
"""Input contract for broker module."""
symbol: str
order_id: str
entry_price: float
stop_price: float
target_price: float
shares: int
order_type: str = "limit" # "limit" or "market"
@dataclass
class BrokerResult(ModuleResult):
"""Output contract for broker module."""
symbol: str = ""
status: str = "" # "submitted", "rejected", "error"
broker_order_id: str | None = None
submitted_at: datetime | None = None
+274
View File
@@ -0,0 +1,274 @@
"""Telemetry collection and event emission for module observability."""
from __future__ import annotations
import time
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from core.contracts import ModuleMetrics
logger = logging.getLogger(__name__)
@dataclass
class TelemetryCollector:
"""Collect metrics during module execution."""
module_name: str
start_time: float = field(default_factory=time.time)
input_count: int = 0
output_count: int = 0
error_count: int = 0
warnings: list[str] = field(default_factory=list)
custom_metrics: dict[str, Any] = field(default_factory=dict)
def record_input(self, count: int = 1) -> None:
"""Record input items processed."""
self.input_count += count
def record_output(self, count: int = 1) -> None:
"""Record output items produced."""
self.output_count += count
def record_error(self) -> None:
"""Record an error occurred."""
self.error_count += 1
def record_warning(self, message: str) -> None:
"""Record a warning."""
self.warnings.append(message)
def record_metric(self, name: str, value: float | int | str) -> None:
"""Record a custom metric."""
self.custom_metrics[name] = value
def finalize(self) -> ModuleMetrics:
"""Finalize and return metrics."""
duration_ms = (time.time() - self.start_time) * 1000
return ModuleMetrics(
module_name=self.module_name,
timestamp=datetime.now(),
duration_ms=duration_ms,
input_count=self.input_count,
output_count=self.output_count,
error_count=self.error_count,
warnings=self.warnings,
custom_metrics=self.custom_metrics,
)
class TelemetryEmitter:
"""Emit telemetry events to event store and observability systems."""
def __init__(self, event_store=None):
"""Initialize emitter.
Args:
event_store: Optional EventStore for persisting telemetry events
"""
self.event_store = event_store
self.callbacks: list[Callable[[dict], None]] = []
def add_callback(self, callback: Callable[[dict], None]) -> None:
"""Add a callback to receive telemetry events."""
self.callbacks.append(callback)
def emit_module_tick(
self,
module_name: str,
stage: str,
metrics: ModuleMetrics | None = None,
errors: list[dict] | None = None,
correlation_id: str | None = None,
) -> None:
"""Emit a module tick event.
Args:
module_name: Name of module (e.g., "ingestion", "evaluation")
stage: Stage name (e.g., "started", "processing", "completed")
metrics: Optional ModuleMetrics object
errors: Optional list of error details
correlation_id: Correlation ID linking to signal_ready event
"""
event = {
"type": "module_tick",
"timestamp": datetime.now().isoformat(),
"module": module_name,
"stage": stage,
"metrics": metrics.to_dict() if metrics else None,
"errors": errors or [],
"correlation_id": correlation_id,
}
self._emit(event)
def emit_data_validation(
self,
module_name: str,
validation_type: str,
valid: bool,
details: dict,
correlation_id: str | None = None,
) -> None:
"""Emit a data validation event.
Args:
module_name: Name of module performing validation
validation_type: Type of validation ("schema", "checksum", "business_rules")
valid: Whether validation passed
details: Validation details (errors, warnings, statistics)
correlation_id: Optional correlation ID
"""
event = {
"type": "data_validation",
"timestamp": datetime.now().isoformat(),
"module": module_name,
"validation_type": validation_type,
"valid": valid,
"details": details,
"correlation_id": correlation_id,
}
self._emit(event)
def emit_symbol_evaluation_detail(
self,
symbol: str,
stage: str,
metrics: dict[str, Any],
passed: bool,
blocking_reason: str | None = None,
correlation_id: str | None = None,
) -> None:
"""Emit detailed evaluation event for a symbol.
Args:
symbol: Symbol being evaluated
stage: Evaluation stage (e.g., "bars_loaded", "gap_checked", "rvol_calculated")
metrics: Metrics at this stage
passed: Whether this stage passed
blocking_reason: If not passed, why
correlation_id: Optional correlation ID
"""
event = {
"type": "symbol_evaluation_detail",
"timestamp": datetime.now().isoformat(),
"symbol": symbol,
"stage": stage,
"metrics": metrics,
"passed": passed,
"blocking_reason": blocking_reason,
"correlation_id": correlation_id,
}
self._emit(event)
def _emit(self, event: dict) -> None:
"""Emit event to all registered callbacks and event store."""
logger.debug(f"Telemetry: {event['type']} - {event.get('module', event.get('symbol', 'n/a'))}")
# Call all registered callbacks
for callback in self.callbacks:
try:
callback(event)
except Exception as e:
logger.error(f"Telemetry callback failed: {e}")
# Emit to event store if available
if self.event_store:
try:
# Import here to avoid circular dependency
from storage.event_schema import ModuleTickEvent, DataValidationEvent
if event["type"] == "module_tick":
self.event_store.emit(
ModuleTickEvent(
timestamp=datetime.now(),
module=event["module"],
stage=event["stage"],
duration_ms=event["metrics"]["duration_ms"]
if event["metrics"]
else 0,
input_count=event["metrics"]["input_count"]
if event["metrics"]
else 0,
output_count=event["metrics"]["output_count"]
if event["metrics"]
else 0,
metrics=event["metrics"]["custom_metrics"]
if event["metrics"]
else {},
errors=event["errors"],
correlation_id=event.get("correlation_id"),
)
)
elif event["type"] == "data_validation":
self.event_store.emit(
DataValidationEvent(
timestamp=datetime.now(),
module=event["module"],
validation_type=event["validation_type"],
valid=event["valid"],
details=event["details"],
correlation_id=event.get("correlation_id"),
)
)
except Exception as e:
logger.error(f"Failed to emit telemetry event to store: {e}")
# Global instance (can be overridden in tests)
_telemetry_emitter: TelemetryEmitter | None = None
def set_telemetry_emitter(emitter: TelemetryEmitter) -> None:
"""Set the global telemetry emitter."""
global _telemetry_emitter
_telemetry_emitter = emitter
def get_telemetry_emitter() -> TelemetryEmitter:
"""Get the global telemetry emitter."""
global _telemetry_emitter
if _telemetry_emitter is None:
_telemetry_emitter = TelemetryEmitter()
return _telemetry_emitter
def emit_module_tick(
module_name: str,
stage: str,
metrics: ModuleMetrics | None = None,
errors: list[dict] | None = None,
correlation_id: str | None = None,
) -> None:
"""Convenience function to emit module tick."""
get_telemetry_emitter().emit_module_tick(module_name, stage, metrics, errors, correlation_id)
def emit_data_validation(
module_name: str,
validation_type: str,
valid: bool,
details: dict,
correlation_id: str | None = None,
) -> None:
"""Convenience function to emit data validation."""
get_telemetry_emitter().emit_data_validation(
module_name, validation_type, valid, details, correlation_id
)
def emit_symbol_evaluation_detail(
symbol: str,
stage: str,
metrics: dict[str, Any],
passed: bool,
blocking_reason: str | None = None,
correlation_id: str | None = None,
) -> None:
"""Convenience function to emit symbol evaluation detail."""
get_telemetry_emitter().emit_symbol_evaluation_detail(
symbol, stage, metrics, passed, blocking_reason, correlation_id
)
+284
View File
@@ -0,0 +1,284 @@
"""Data validation utilities for module boundaries."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from core.contracts import ValidationFailure
@dataclass
class SchemaValidator:
"""Validate data against a schema."""
required_fields: dict[str, type] # {field_name: expected_type}
optional_fields: dict[str, type] | None = None
def __post_init__(self):
if self.optional_fields is None:
self.optional_fields = {}
def validate(self, data: dict) -> tuple[bool, list[dict]]:
"""Validate data against schema.
Args:
data: Dictionary to validate
Returns:
(is_valid, list of failure dicts)
"""
failures = []
# Check required fields
for field_name, expected_type in self.required_fields.items():
if field_name not in data:
failures.append(
{
"field": field_name,
"value": None,
"reason": "Required field missing",
"severity": "error",
}
)
elif not isinstance(data[field_name], expected_type):
failures.append(
{
"field": field_name,
"value": data[field_name],
"reason": f"Expected {expected_type.__name__}, got {type(data[field_name]).__name__}",
"severity": "error",
}
)
# Check optional fields if present
if self.optional_fields:
for field_name, expected_type in self.optional_fields.items():
if field_name in data and not isinstance(data[field_name], expected_type):
failures.append(
{
"field": field_name,
"value": data[field_name],
"reason": f"Expected {expected_type.__name__}, got {type(data[field_name]).__name__}",
"severity": "warning",
}
)
return len([f for f in failures if f.severity == "error"]) == 0, failures
class RangeValidator:
"""Validate numeric values are within expected ranges."""
def __init__(self, min_val: float | None = None, max_val: float | None = None):
self.min_val = min_val
self.max_val = max_val
def validate(self, value: float, field_name: str = "value") -> tuple[bool, dict | None]:
"""Validate value is in range.
Args:
value: Numeric value to validate
field_name: Name of field for error message
Returns:
(is_valid, failure dict or None)
"""
if self.min_val is not None and value < self.min_val:
return False, {
"field": field_name,
"value": value,
"reason": f"Value {value} below minimum {self.min_val}",
"severity": "error",
}
if self.max_val is not None and value > self.max_val:
return False, {
"field": field_name,
"value": value,
"reason": f"Value {value} above maximum {self.max_val}",
"severity": "error",
}
return True, None
class ChecksumValidator:
"""Validate data integrity using checksums."""
@staticmethod
def compute_checksum(data: dict | list) -> str:
"""Compute simple checksum for data.
Args:
data: Dictionary or list to checksum
Returns:
Hex checksum string
"""
import hashlib
import json
data_str = json.dumps(data, sort_keys=True, default=str)
return hashlib.md5(data_str.encode()).hexdigest()
@staticmethod
def validate_checksum(
data: dict | list, expected_checksum: str
) -> tuple[bool, dict | None]:
"""Validate data matches checksum.
Args:
data: Data to validate
expected_checksum: Expected checksum value
Returns:
(is_valid, failure dict or None)
"""
actual_checksum = ChecksumValidator.compute_checksum(data)
if actual_checksum != expected_checksum:
return False, {
"field": "checksum",
"value": actual_checksum,
"reason": f"Checksum mismatch: expected {expected_checksum}, got {actual_checksum}",
"severity": "error",
}
return True, None
class BusinessRuleValidator:
"""Validate data against business logic rules."""
def __init__(self):
self.rules: dict[str, Callable[[Any], tuple[bool, str]]] = {}
def add_rule(self, rule_name: str, rule_fn: Callable[[Any], tuple[bool, str]]) -> None:
"""Add a business rule.
Args:
rule_name: Name of the rule
rule_fn: Function that takes data and returns (is_valid, reason)
"""
self.rules[rule_name] = rule_fn
def validate(self, data: Any) -> tuple[bool, list[dict]]:
"""Apply all business rules.
Args:
data: Data to validate
Returns:
(is_valid, list of failure dicts)
"""
failures = []
for rule_name, rule_fn in self.rules.items():
try:
is_valid, reason = rule_fn(data)
if not is_valid:
failures.append(
{
"field": "business_rule",
"value": None,
"reason": f"{rule_name}: {reason}",
"severity": "error",
}
)
except Exception as e:
failures.append(
{
"field": "business_rule",
"value": None,
"reason": f"{rule_name} raised exception: {e}",
"severity": "error",
}
)
return len(failures) == 0, failures
# Concrete validators for common cases
class BarDataValidator:
"""Validate OHLCV bar data."""
@staticmethod
def validate_bar(bar: dict) -> tuple[bool, list[dict]]:
"""Validate a single bar.
Args:
bar: Bar dictionary with {timestamp, open, high, low, close, volume}
Returns:
(is_valid, list of failure dicts)
"""
failures = []
# Check required fields
required = {"timestamp", "open", "high", "low", "close", "volume"}
for field in required:
if field not in bar:
failures.append(
{
"field": field,
"value": None,
"reason": "Missing required field",
"severity": "error",
}
)
if failures:
return False, failures
# Check value consistency
open_val = bar["open"]
high_val = bar["high"]
low_val = bar["low"]
close_val = bar["close"]
volume_val = bar["volume"]
# High must be >= all others
if high_val < open_val or high_val < close_val or high_val < low_val:
failures.append(
{
"field": "high",
"value": high_val,
"reason": f"High ({high_val}) must be >= open ({open_val}), close ({close_val}), low ({low_val})",
"severity": "error",
}
)
# Low must be <= all others
if low_val > open_val or low_val > close_val or low_val > high_val:
failures.append(
{
"field": "low",
"value": low_val,
"reason": f"Low ({low_val}) must be <= open ({open_val}), close ({close_val}), high ({high_val})",
"severity": "error",
}
)
# Volume should be non-negative
if volume_val < 0:
failures.append(
{
"field": "volume",
"value": volume_val,
"reason": "Volume cannot be negative",
"severity": "error",
}
)
# Warn on zero volume
if volume_val == 0:
failures.append(
{
"field": "volume",
"value": volume_val,
"reason": "Zero volume bar",
"severity": "warning",
}
)
return len([f for f in failures if f.severity == "error"]) == 0, failures
+35
View File
@@ -0,0 +1,35 @@
"""Run the dashboard API + UI standalone (read-only against the event DB).
python dashboard_api.py
# open http://127.0.0.1:8010
Env: DASHBOARD_HOST, DASHBOARD_PORT, WATCHER_EVENT_DB_PATH,
TRADING_EXECUTION_MODE. For approve/reject/exit buttons to act, run the full
orchestrator (run_live_paper.py) instead, which attaches the execution
service to this same server.
"""
from __future__ import annotations
import os
from api.main import DashboardState, create_server
def main() -> None:
host = os.environ.get("DASHBOARD_HOST", "127.0.0.1")
port = int(os.environ.get("DASHBOARD_PORT", "8010"))
db_path = os.environ.get("WATCHER_EVENT_DB_PATH", "./data/momentum.duckdb")
mode = os.environ.get("TRADING_EXECUTION_MODE", "alpaca_paper")
state = DashboardState(db_path, execution_service=None, execution_mode=mode)
server = create_server(state, host, port)
print(f"[dashboard] serving http://{host}:{port} (event db: {db_path}, mode: {mode})")
print("[dashboard] read-only mode — run run_live_paper.py for live actions")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[dashboard] stopped")
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
"""Fetch 1-minute Alpaca bars into the research market database.
python fetch_minute_bars.py AAPL TSLA --lookback 390
python fetch_minute_bars.py SNDL --daily-days 30
"""
from __future__ import annotations
import argparse
import sys
from dotenv import load_dotenv
from alpaca_paper.client import AlpacaPaperClient
from alpaca_paper.settings import AlpacaPaperSettings
from research.ingestion.market_data import ingest_daily_history, ingest_live_minute_bars
from research.multi_schema import open_research_db
def main(argv: list[str] | None = None) -> int:
load_dotenv()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("symbols", nargs="+", help="ticker symbols")
parser.add_argument("--lookback", type=int, default=390, help="minutes of 1-min bars")
parser.add_argument("--daily-days", type=int, default=30, help="days of daily backfill")
parser.add_argument("--feed", default=None, help="alpaca feed (default: settings/iex)")
args = parser.parse_args(argv)
settings = AlpacaPaperSettings.from_env()
if not settings.is_configured:
print("error: set ALPACA_API_KEY and ALPACA_SECRET_KEY (see .env.example)")
return 1
client = AlpacaPaperClient(settings)
con = open_research_db("market")
symbols = [s.upper() for s in args.symbols]
daily = ingest_daily_history(con, client, symbols, days=args.daily_days)
print(f"daily bars: {daily.daily_rows} rows" + (f" errors={daily.errors}" if daily.errors else ""))
minute = ingest_live_minute_bars(
con, client, symbols, lookback_minutes=args.lookback, feed=args.feed
)
print(f"minute bars: {minute.minute_rows} rows for {sorted(set(minute.symbols))}"
+ (f" errors={minute.errors}" if minute.errors else ""))
con.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
"""Milestone 3 verification: Schwab integration health check.
python milestone3_verify.py
Walks every layer of the Schwab integration and prints PASS/WARN/FAIL.
Designed to be useful both with and without credentials: without them it
verifies graceful degradation (fallback account, empty orders,
unauthenticated health) instead of crashing.
"""
from __future__ import annotations
import sys
from dotenv import load_dotenv
def check(label: str, fn):
try:
status, detail = fn()
except Exception as exc: # noqa: BLE001
status, detail = "FAIL", str(exc)
print(f"[{status:^4}] {label}: {detail}")
return status != "FAIL"
def main() -> int:
load_dotenv()
from schwab.auth.lifecycle import TokenLifecycle
from schwab.auth.token_store import TokenStore
from schwab.health.reporter import HealthReporter
from schwab.market.client import SchwabApiError, SchwabMarketClient
from schwab.orders.reader import OrdersReader
from schwab.positions.reader import PositionsReader
from schwab.settings import SchwabSettings
settings = SchwabSettings.from_env()
ok = True
def c_settings():
creds = settings.has_broker_credentials or settings.has_market_data_credentials
return ("PASS" if creds else "WARN",
f"app keys {'present' if creds else 'absent'}; token_path={settings.token_path}")
ok &= check("settings", c_settings)
def c_token():
store = TokenStore(settings.token_path)
bundle = store.load()
if bundle is None:
return "WARN", "no token file — run OAuth flow to authenticate"
return ("PASS" if not bundle.is_expired else "WARN",
f"token loaded, expired={bundle.is_expired}")
ok &= check("token store", c_token)
lifecycle = TokenLifecycle(settings)
def c_lifecycle():
status = lifecycle.status()
if status.get("authenticated"):
return "PASS", f"authenticated (expired={status.get('expired')})"
return "WARN", f"unauthenticated: {status.get('reason')}"
ok &= check("token lifecycle", c_lifecycle)
def c_health():
report = HealthReporter(settings=settings, lifecycle=lifecycle).check()
d = report.to_dict()
return "PASS", f"overall={d.get('status')} checks={len(d.get('checks') or [])}"
ok &= check("health reporter", c_health)
def c_positions():
reader = PositionsReader(settings=settings, lifecycle=lifecycle)
summary = reader.read_account_summary()
if getattr(summary, "is_fallback", False):
return "WARN", f"fallback summary ({summary.account_id}) — authenticate for live data"
return "PASS", f"account {summary.account_id}"
ok &= check("positions reader", c_positions)
def c_orders():
reader = OrdersReader(settings=settings, lifecycle=lifecycle)
orders = reader.read_orders()
return "PASS", f"{len(orders)} orders (empty is expected when unauthenticated)"
ok &= check("orders reader", c_orders)
def c_market():
client = SchwabMarketClient(settings=settings, lifecycle=lifecycle)
try:
quotes = client.get_quotes(["AAPL"])
return "PASS", f"quotes returned for {list(quotes)}"
except SchwabApiError as exc:
if exc.status == 401:
return "WARN", "market client correctly raises 401 when unauthenticated"
raise
ok &= check("market client", c_market)
print("\nmilestone 3:", "VERIFIED (warnings are expected without live Schwab auth)" if ok else "FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
View File
Whitespace-only changes.
+26
View File
@@ -0,0 +1,26 @@
"""Models for Milestone 2.
Event and data models for the event-first architecture.
"""
from pydantic import BaseModel, Field
class EventMode(str):
"""Runtime modes for events."""
REPLAY = "replay"
PAPER = "paper"
LIVE = "live"
class BaseEvent(BaseModel):
"""Base event with common fields."""
timestamp: str = Field(description="Event timestamp (ISO format)")
mode: EventMode = Field(description="Runtime mode (replay/paper/live)")
event_type: str = Field(description="Event type identifier")
correlation_id: str | None = Field(
default=None, description="Correlation ID for causal chain"
)
message: str = Field(description="Human-readable message")
+18
View File
@@ -0,0 +1,18 @@
{
"name": "momentum",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"claude": "^0.1.1"
}
},
"node_modules/claude": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz",
"integrity": "sha512-j7oSibqQdIODNhkI1sEJzHMiPsF43L/GqNbcA+eDDyGM10+x2sH9NW/PK6vM3z0J2tLDKMBcc5ZjVaoRinhuCA==",
"license": "ISC"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"claude": "^0.1.1"
}
}
+37
View File
@@ -0,0 +1,37 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "momentum"
version = "0.4.0"
description = "Small-cap momentum research & paper-trading system"
requires-python = ">=3.11"
dependencies = [
"duckdb>=1.0.0",
"pandas>=2.0.0",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"pyyaml>=6.0",
"typer>=0.9.0",
"rich>=13.0.0",
"numpy>=1.24.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.uv]
# This is a runnable app (flat layout: scripts at the repo root plus api/,
# storage/, runtime/, ... packages), not a distributable library. Don't try to
# build/install it as a wheel — just provision the dependencies. Code runs via
# `uv run run_live_paper.py` from the repo root (pytest uses pythonpath="." ).
package = false
+7
View File
@@ -0,0 +1,7 @@
duckdb>=1.0.0
pandas>=2.0.0
pydantic>=2.0.0
pytest>=7.0.0
python-dotenv>=1.0.0
pyyaml>=6.0
numpy>=1.24.0
+1
View File
@@ -0,0 +1 @@
"""Research data layer: ingestion, schema, and queries."""
+16
View File
@@ -0,0 +1,16 @@
"""Research ingestion: bars, news, signal scans, and watcher providers."""
from research.ingestion.market_data import ( # noqa: F401
IngestionResult,
classify_session,
discover_active_symbols,
ingest_daily_history,
ingest_live_minute_bars,
)
from research.ingestion.scheduler import Scheduler, ScheduledTask # noqa: F401
from research.ingestion.signals import scan_gappers, store_scanner_snapshot # noqa: F401
from research.ingestion.watcher_task import ( # noqa: F401
LiveWatchlistProvider,
ResearchWatchlistProvider,
run_watcher_tick,
)
+235
View File
@@ -0,0 +1,235 @@
"""Market data ingestion: Alpaca bars -> research market.duckdb.
This module fixes the root cause of "live paper trading never worked":
the watcher reads *minute* bars from the research database, but the old
ingestion only ever wrote *daily* bars for a handful of mega-caps and
hardcoded ``is_regular_hours = TRUE`` on every row. Here we:
* ingest 1-minute bars (IEX feed by default) for a configurable symbol list,
* derive ``is_premarket`` / ``is_regular_hours`` / ``is_afterhours`` from the
bar timestamp in US/Eastern (regular session 09:30-16:00),
* backfill daily bars so the watcher can compute gap %% and relative volume.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
REGULAR_OPEN = time(9, 30)
REGULAR_CLOSE = time(16, 0)
PREMARKET_OPEN = time(4, 0)
AFTERHOURS_CLOSE = time(20, 0)
def classify_session(ts_utc: datetime) -> tuple[date, bool, bool, bool]:
"""Return (session_date, is_premarket, is_regular_hours, is_afterhours)."""
if ts_utc.tzinfo is None:
ts_utc = ts_utc.replace(tzinfo=timezone.utc)
local = ts_utc.astimezone(EASTERN)
t = local.time()
is_pre = PREMARKET_OPEN <= t < REGULAR_OPEN
is_reg = REGULAR_OPEN <= t < REGULAR_CLOSE
is_aft = REGULAR_CLOSE <= t < AFTERHOURS_CLOSE
return local.date(), is_pre, is_reg, is_aft
def parse_alpaca_timestamp(raw: str) -> datetime:
"""Alpaca returns RFC3339 timestamps like '2026-06-11T13:30:00Z'."""
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
@dataclass
class IngestionResult:
minute_rows: int = 0
daily_rows: int = 0
symbols: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def upsert_minute_bars(
con,
symbol: str,
bars: list[dict],
source_provider: str = "alpaca_iex",
) -> int:
"""Insert/replace Alpaca minute bars for one symbol. Returns row count."""
rows = []
for bar in bars:
ts = parse_alpaca_timestamp(bar["t"])
session_date, is_pre, is_reg, is_aft = classify_session(ts)
rows.append(
(
symbol,
ts.astimezone(timezone.utc).replace(tzinfo=None),
session_date,
is_pre,
is_reg,
is_aft,
float(bar["o"]),
float(bar["h"]),
float(bar["l"]),
float(bar["c"]),
int(bar["v"]),
float(bar.get("vw") or bar["c"]),
None, # spread_pct (not available from bars endpoint)
False, # halt_status
source_provider,
1.0, # quality_score: real exchange data
)
)
if not rows:
return 0
con.executemany(
"""
INSERT OR REPLACE INTO minute_bars (
symbol, timestamp, session_date,
is_premarket, is_regular_hours, is_afterhours,
open, high, low, close, volume, vwap,
spread_pct, halt_status, source_provider, quality_score
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
return len(rows)
def upsert_daily_bars(con, symbol: str, bars: list[dict]) -> int:
"""Insert/replace Alpaca daily bars, deriving previous_close per row."""
ordered = sorted(bars, key=lambda b: b["t"])
rows = []
prev_close: float | None = None
for bar in ordered:
ts = parse_alpaca_timestamp(bar["t"]).astimezone(EASTERN)
close = float(bar["c"])
rows.append(
(
symbol,
ts.date(),
float(bar["o"]),
float(bar["h"]),
float(bar["l"]),
close,
int(bar["v"]),
float(bar.get("vw") or close),
prev_close,
)
)
prev_close = close
if not rows:
return 0
con.executemany(
"""
INSERT OR REPLACE INTO daily_bars (
symbol, trade_date, open, high, low, close, volume, vwap,
previous_close
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
return len(rows)
def ingest_live_minute_bars(
con,
client,
symbols: list[str],
lookback_minutes: int = 240,
feed: str | None = None,
) -> IngestionResult:
"""Pull recent 1-minute bars for `symbols` into the research database.
`client` is an AlpacaPaperClient (or anything with get_minute_bars).
Free Alpaca keys can only see IEX data, and the SIP feed additionally
embargoes the most recent 15 minutes; we end the window slightly in the
past so the request never 403s.
"""
result = IngestionResult()
if not symbols:
return result
now = datetime.now(timezone.utc)
start = now - timedelta(minutes=lookback_minutes)
end = now - timedelta(seconds=30)
try:
bars_by_symbol = client.get_minute_bars(
symbols,
start_iso=start.isoformat().replace("+00:00", "Z"),
end_iso=end.isoformat().replace("+00:00", "Z"),
feed=feed,
)
except Exception as exc: # noqa: BLE001 - report, don't crash the loop
result.errors.append(f"minute bars fetch failed: {exc}")
return result
for symbol, bars in bars_by_symbol.items():
try:
count = upsert_minute_bars(con, symbol, bars)
if count:
result.minute_rows += count
result.symbols.append(symbol)
except Exception as exc: # noqa: BLE001
result.errors.append(f"{symbol}: minute upsert failed: {exc}")
return result
def ingest_daily_history(
con,
client,
symbols: list[str],
days: int = 30,
) -> IngestionResult:
"""Backfill daily bars so gap %% / relative volume have a baseline."""
result = IngestionResult()
if not symbols:
return result
start = (datetime.now(timezone.utc) - timedelta(days=days * 2)).date()
try:
bars_by_symbol = client.get_daily_bars(
symbols, start_iso=f"{start.isoformat()}T00:00:00Z"
)
except Exception as exc: # noqa: BLE001
result.errors.append(f"daily bars fetch failed: {exc}")
return result
for symbol, bars in bars_by_symbol.items():
try:
count = upsert_daily_bars(con, symbol, bars)
if count:
result.daily_rows += count
result.symbols.append(symbol)
except Exception as exc: # noqa: BLE001
result.errors.append(f"{symbol}: daily upsert failed: {exc}")
return result
def discover_active_symbols(
client,
top: int = 20,
price_min: float = 1.0,
price_max: float = 20.0,
) -> list[str]:
"""Use Alpaca's most-actives screener to find live momentum candidates.
Filters the screener output to the strategy's price band using latest
trades. Failures return an empty list so the caller can fall back to a
static symbol list.
"""
try:
actives = client.get_most_actives(top=top * 3, by="volume")
symbols = [a["symbol"] for a in actives if a.get("symbol")]
if not symbols:
return []
trades = client.get_latest_trades(symbols[:50])
in_band = []
for symbol in symbols:
trade = trades.get(symbol)
if not trade:
continue
price = float(trade.get("p") or 0.0)
if price_min <= price <= price_max:
in_band.append(symbol)
if len(in_band) >= top:
break
return in_band
except Exception: # noqa: BLE001 - screener needs a paid feed sometimes
return []
+158
View File
@@ -0,0 +1,158 @@
"""RSS news ingestion: feed XML -> raw_fetch_attempts + raw_news_items.
Stdlib-only (urllib + xml.etree). Append-only raw landing tables; downstream
enrichment can dedupe via payload_hash.
"""
from __future__ import annotations
import hashlib
import re
import urllib.request
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime, timezone
PARSER_VERSION = "rss-v2"
TICKER_RE = re.compile(r"\((?:NASDAQ|NYSE|AMEX|OTC)[:\s]+([A-Z]{1,5})\)")
@dataclass
class RssIngestResult:
source: str
fetch_attempt_id: str = ""
item_count: int = 0
http_status: int | None = None
error: str | None = None
new_items: list[str] = field(default_factory=list)
def _text(element, tag: str) -> str:
child = element.find(tag)
return (child.text or "").strip() if child is not None else ""
def parse_rss(xml_bytes: bytes) -> list[dict]:
"""Parse RSS 2.0 / Atom into a list of raw item dicts."""
root = ET.fromstring(xml_bytes)
items: list[dict] = []
# RSS 2.0
for item in root.iter("item"):
items.append(
{
"title": _text(item, "title"),
"url": _text(item, "link"),
"published": _text(item, "pubDate"),
"snippet": _text(item, "description")[:500],
}
)
# Atom
ns = "{http://www.w3.org/2005/Atom}"
for entry in root.iter(f"{ns}entry"):
link = entry.find(f"{ns}link")
items.append(
{
"title": _text(entry, f"{ns}title"),
"url": link.get("href", "") if link is not None else "",
"published": _text(entry, f"{ns}updated"),
"snippet": _text(entry, f"{ns}summary")[:500],
}
)
return items
def extract_tickers(text: str) -> list[str]:
return sorted(set(TICKER_RE.findall(text or "")))
def ingest_rss_feed(
con,
source: str,
url: str,
timeout: int = 15,
ingest_run_id: str | None = None,
) -> RssIngestResult:
"""Fetch one RSS feed and land items into raw_news_items."""
result = RssIngestResult(source=source)
attempt_id = str(uuid.uuid4())
result.fetch_attempt_id = attempt_id
fetched_at = datetime.now(timezone.utc).replace(tzinfo=None)
payload: bytes = b""
try:
request = urllib.request.Request(
url, headers={"User-Agent": "momentum-research/1.0"}
)
with urllib.request.urlopen(request, timeout=timeout) as resp:
result.http_status = resp.status
payload = resp.read()
items = parse_rss(payload)
except Exception as exc: # noqa: BLE001
result.error = str(exc)
items = []
payload_hash = hashlib.sha256(payload).hexdigest() if payload else None
con.execute(
"""
INSERT INTO raw_fetch_attempts (
id, source, capability, fetched_at, ingest_run_id,
http_status, item_count, error_msg, payload_hash, parser_version
) VALUES (?, ?, 'news_rss', ?, ?, ?, ?, ?, ?, ?)
""",
[
attempt_id,
source,
fetched_at,
ingest_run_id,
result.http_status,
len(items),
result.error,
payload_hash,
PARSER_VERSION,
],
)
for item in items:
item_hash = hashlib.sha256(
f"{source}|{item['url']}|{item['title']}".encode()
).hexdigest()
exists = con.execute(
"SELECT 1 FROM raw_news_items WHERE payload_hash = ? LIMIT 1",
[item_hash],
).fetchone()
if exists:
continue
item_id = str(uuid.uuid4())
con.execute(
"""
INSERT INTO raw_news_items (
id, fetch_attempt_id, source, raw_url, raw_title,
raw_published_at, raw_body_snippet, raw_tickers,
payload_hash, parser_version, fetched_at, ingest_run_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
item_id,
attempt_id,
source,
item["url"],
item["title"],
item["published"],
item["snippet"],
",".join(extract_tickers(f"{item['title']} {item['snippet']}")),
item_hash,
PARSER_VERSION,
fetched_at,
ingest_run_id,
],
)
result.new_items.append(item_id)
result.item_count = len(items)
return result
def ingest_all_feeds(con, feeds: dict[str, str]) -> list[RssIngestResult]:
"""Ingest a {source_name: url} mapping; never raises."""
run_id = str(uuid.uuid4())
return [
ingest_rss_feed(con, source, url, ingest_run_id=run_id)
for source, url in feeds.items()
]
+68
View File
@@ -0,0 +1,68 @@
"""Tiny interval scheduler used by the orchestrator and research jobs.
Each task has its own interval; ``run_pending`` executes whatever is due.
Task failures are recorded and never propagate, so one flaky data source
cannot stall the trading loop.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class ScheduledTask:
name: str
func: Callable[[], object]
interval_seconds: float
enabled: bool = True
last_run: float = 0.0
last_error: str | None = None
run_count: int = 0
error_count: int = 0
def due(self, now: float) -> bool:
return self.enabled and (now - self.last_run) >= self.interval_seconds
@dataclass
class Scheduler:
tasks: list[ScheduledTask] = field(default_factory=list)
def add(
self,
name: str,
func: Callable[[], object],
interval_seconds: float,
enabled: bool = True,
) -> ScheduledTask:
task = ScheduledTask(name, func, interval_seconds, enabled=enabled)
self.tasks.append(task)
return task
def run_pending(self, now: float | None = None) -> dict[str, object]:
"""Run all due tasks once. Returns {task_name: result_or_error}."""
now = time.monotonic() if now is None else now
results: dict[str, object] = {}
for task in self.tasks:
if not task.due(now):
continue
task.last_run = now
task.run_count += 1
try:
results[task.name] = task.func()
task.last_error = None
except Exception as exc: # noqa: BLE001
task.error_count += 1
task.last_error = str(exc)
results[task.name] = exc
return results
def run_forever(self, tick_seconds: float = 1.0, stop_check=None) -> None:
while True:
if stop_check and stop_check():
return
self.run_pending()
time.sleep(tick_seconds)
+136
View File
@@ -0,0 +1,136 @@
"""Signal scanning: derive gapper candidates and scanner snapshots.
Reads minute/daily bars from the market database, computes gap %% and
relative volume, and writes ranked rows into scanner_snapshots so the
dashboard and research notebooks can replay what the scanner saw.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import date, datetime, timezone
SCANNER_VERSION = "gapper-v2"
@dataclass
class GapperCandidate:
symbol: str
price: float
gap_pct: float
cumulative_volume: int
relative_volume: float
vwap: float | None
rank: int = 0
def scan_gappers(
con,
session_date: date,
min_gap_pct: float = 5.0,
min_relative_volume: float = 2.0,
price_min: float = 1.0,
price_max: float = 20.0,
limit: int = 25,
) -> list[GapperCandidate]:
"""Find symbols gapping up on elevated volume for a session."""
rows = con.execute(
"""
WITH latest AS (
SELECT symbol, close, vwap,
SUM(volume) OVER (PARTITION BY symbol) AS cum_volume,
ROW_NUMBER() OVER (
PARTITION BY symbol ORDER BY timestamp DESC
) AS rn
FROM minute_bars
WHERE session_date = ?
),
prev AS (
SELECT symbol, close AS prev_close,
ROW_NUMBER() OVER (
PARTITION BY symbol ORDER BY trade_date DESC
) AS rn
FROM daily_bars
WHERE trade_date < ?
),
avg_vol AS (
SELECT symbol, AVG(volume) AS adv
FROM (
SELECT symbol, volume,
ROW_NUMBER() OVER (
PARTITION BY symbol ORDER BY trade_date DESC
) AS rn
FROM daily_bars
WHERE trade_date < ?
)
WHERE rn <= 20
GROUP BY symbol
)
SELECT l.symbol, l.close, l.vwap, l.cum_volume,
p.prev_close, a.adv
FROM latest l
JOIN prev p ON p.symbol = l.symbol AND p.rn = 1
LEFT JOIN avg_vol a ON a.symbol = l.symbol
WHERE l.rn = 1 AND l.close BETWEEN ? AND ?
""",
[session_date, session_date, session_date, price_min, price_max],
).fetchall()
candidates: list[GapperCandidate] = []
for symbol, close, vwap, cum_volume, prev_close, adv in rows:
if not prev_close or prev_close <= 0:
continue
gap_pct = (float(close) - float(prev_close)) / float(prev_close) * 100.0
rvol = (float(cum_volume) / float(adv)) if adv and adv > 0 else 0.0
if gap_pct >= min_gap_pct and rvol >= min_relative_volume:
candidates.append(
GapperCandidate(
symbol=symbol,
price=float(close),
gap_pct=gap_pct,
cumulative_volume=int(cum_volume),
relative_volume=rvol,
vwap=float(vwap) if vwap is not None else None,
)
)
candidates.sort(key=lambda c: (c.gap_pct * max(c.relative_volume, 0.1)), reverse=True)
for i, candidate in enumerate(candidates[:limit], start=1):
candidate.rank = i
return candidates[:limit]
def store_scanner_snapshot(
con, candidates: list[GapperCandidate], snapshot_time: datetime | None = None
) -> int:
"""Persist a scan into scanner_snapshots. Returns rows written."""
ts = (snapshot_time or datetime.now(timezone.utc)).replace(tzinfo=None)
rows = [
(
str(uuid.uuid4()),
ts,
c.symbol,
c.rank,
c.price,
c.gap_pct,
c.cumulative_volume,
c.relative_volume,
c.vwap,
(c.price - c.vwap) / c.vwap * 100.0 if c.vwap else None,
SCANNER_VERSION,
)
for c in candidates
]
if not rows:
return 0
con.executemany(
"""
INSERT INTO scanner_snapshots (
id, snapshot_time, symbol, rank, price, gap_pct,
cumulative_volume, relative_volume, vwap, distance_from_vwap,
scanner_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
return len(rows)
+186
View File
@@ -0,0 +1,186 @@
"""Watchlist providers and watcher task glue.
Two providers implement the WatchlistProvider protocol:
* ResearchWatchlistProvider — reads candidates and bars from the research
market.duckdb (populated by research.ingestion.market_data). The price
band is configurable via WATCHER_PRICE_MIN / WATCHER_PRICE_MAX, fixing
the old hardcoded ``close BETWEEN 1 AND 20`` filter that silently
excluded every ingested symbol.
* LiveWatchlistProvider — takes an explicit symbol list and pulls bars
straight from Alpaca, for running without a research database.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
import pandas as pd
from research import query as research_query
from research.ingestion.market_data import classify_session, parse_alpaca_timestamp
from runtime.watcher import WatchCandidate, Watcher, WatcherTickResult
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
try:
return float(raw) if raw else default
except ValueError:
return default
@dataclass
class ResearchWatchlistProvider:
"""Candidates + bars from the research market database."""
con: object
price_min: float = field(default_factory=lambda: _env_float("WATCHER_PRICE_MIN", 1.0))
price_max: float = field(default_factory=lambda: _env_float("WATCHER_PRICE_MAX", 20.0))
limit: int = 50
# symbols manually injected from the dashboard; always evaluated regardless
# of the price-band query (e.g. a ticker not yet ingested)
extra_symbols: set = field(default_factory=set)
def add_symbol(self, symbol: str) -> None:
self.extra_symbols.add(symbol.upper())
def get_candidates(self, session_date: date) -> list[WatchCandidate]:
rows = research_query.query_session_symbols(
self.con,
session_date,
price_min=self.price_min,
price_max=self.price_max,
limit=self.limit,
)
seen = set()
candidates = []
for row in rows:
symbol = row["symbol"]
seen.add(symbol)
candidates.append(
WatchCandidate(
symbol=symbol,
last_price=row.get("last_price"),
previous_close=research_query.query_previous_close(
self.con, symbol, session_date
),
avg_daily_volume=research_query.query_avg_daily_volume(
self.con, symbol, session_date
),
source="research",
)
)
# always include manually injected symbols
for symbol in sorted(self.extra_symbols - seen):
candidates.append(
WatchCandidate(
symbol=symbol,
previous_close=research_query.query_previous_close(
self.con, symbol, session_date
),
avg_daily_volume=research_query.query_avg_daily_volume(
self.con, symbol, session_date
),
source="manual",
)
)
return candidates
def get_bars(self, symbol: str, session_date: date) -> pd.DataFrame:
return research_query.query_minute_bars(self.con, symbol, session_date)
@dataclass
class LiveWatchlistProvider:
"""Explicit symbols, bars fetched live from Alpaca (no database needed)."""
client: object
symbols: list[str]
lookback_minutes: int = 420
_daily_cache: dict[str, list[dict]] = field(default_factory=dict)
def _daily_bars(self, symbol: str) -> list[dict]:
if symbol not in self._daily_cache:
start = (datetime.now(timezone.utc) - timedelta(days=60)).date()
try:
payload = self.client.get_daily_bars(
[symbol], start_iso=f"{start.isoformat()}T00:00:00Z"
)
self._daily_cache[symbol] = payload.get(symbol, [])
except Exception: # noqa: BLE001
self._daily_cache[symbol] = []
return self._daily_cache[symbol]
def get_candidates(self, session_date: date) -> list[WatchCandidate]:
candidates = []
for symbol in self.symbols:
daily = self._daily_bars(symbol)
prev_close = None
adv = None
if daily:
# last bar strictly before the session date
prior = [
b for b in daily
if parse_alpaca_timestamp(b["t"]).date() < session_date
]
if prior:
prev_close = float(prior[-1]["c"])
recent = prior[-20:]
adv = sum(float(b["v"]) for b in recent) / len(recent)
candidates.append(
WatchCandidate(
symbol=symbol,
previous_close=prev_close,
avg_daily_volume=adv,
source="live",
)
)
return candidates
def get_bars(self, symbol: str, session_date: date) -> pd.DataFrame:
now = datetime.now(timezone.utc)
start = now - timedelta(minutes=self.lookback_minutes)
try:
payload = self.client.get_minute_bars(
[symbol],
start_iso=start.isoformat().replace("+00:00", "Z"),
end_iso=(now - timedelta(seconds=30)).isoformat().replace("+00:00", "Z"),
)
bars = payload.get(symbol, [])
except Exception: # noqa: BLE001
bars = []
rows = []
for bar in bars:
ts = parse_alpaca_timestamp(bar["t"])
bar_date, is_pre, is_reg, _ = classify_session(ts)
if bar_date != session_date:
continue
rows.append(
{
"symbol": symbol,
"timestamp": ts.astimezone(timezone.utc).replace(tzinfo=None),
"session_date": bar_date,
"is_premarket": is_pre,
"is_regular_hours": is_reg,
"open": float(bar["o"]),
"high": float(bar["h"]),
"low": float(bar["l"]),
"close": float(bar["c"]),
"volume": int(bar["v"]),
"vwap": float(bar.get("vw") or bar["c"]),
"quality_score": 1.0,
}
)
return pd.DataFrame(rows)
def run_watcher_tick(
watcher: Watcher, session_date: date | None = None
) -> WatcherTickResult:
"""One watcher tick with a safe default session date (US/Eastern today)."""
if session_date is None:
session_date, _, _, _ = classify_session(datetime.now(timezone.utc))
return watcher.tick(session_date)
+34
View File
@@ -0,0 +1,34 @@
"""Multi-database layout for research data.
market.duckdb — bars, halts, market context
news.duckdb — raw news items and fetch attempts
signals.duckdb — scanner snapshots and engineered features
All share the same core schema (tables are cheap when empty), which keeps
cross-database queries simple.
"""
from __future__ import annotations
from pathlib import Path
from storage.db import get_connection
RESEARCH_DIR = Path("data/research")
DB_FILES = {
"market": "market.duckdb",
"news": "news.duckdb",
"signals": "signals.duckdb",
}
def research_db_path(name: str, base_dir: str | Path | None = None) -> Path:
base = Path(base_dir) if base_dir else RESEARCH_DIR
filename = DB_FILES.get(name, f"{name}.duckdb")
return base / filename
def open_research_db(name: str, base_dir: str | Path | None = None):
"""Open one of the research databases, creating schema if needed."""
return get_connection(research_db_path(name, base_dir))
+83
View File
@@ -0,0 +1,83 @@
"""Read-side queries over the research market database."""
from __future__ import annotations
from datetime import date
import pandas as pd
def query_session_symbols(
con,
session_date: date,
price_min: float = 1.0,
price_max: float = 20.0,
limit: int = 50,
) -> list[dict]:
"""Symbols with minute bars today whose latest close is inside the band.
Pass price_min=0 / price_max=inf to disable the band.
"""
rows = con.execute(
"""
WITH latest AS (
SELECT symbol, close, volume,
ROW_NUMBER() OVER (
PARTITION BY symbol ORDER BY timestamp DESC
) AS rn,
COUNT(*) OVER (PARTITION BY symbol) AS bar_count
FROM minute_bars
WHERE session_date = ?
)
SELECT symbol, close AS last_price, bar_count
FROM latest
WHERE rn = 1 AND close BETWEEN ? AND ?
ORDER BY bar_count DESC
LIMIT ?
""",
[session_date, price_min, price_max, limit],
).fetchall()
return [
{"symbol": r[0], "last_price": float(r[1]), "bar_count": int(r[2])}
for r in rows
]
def query_minute_bars(con, symbol: str, session_date: date) -> pd.DataFrame:
"""All of today's minute bars for a symbol, chronological."""
return con.execute(
"""
SELECT symbol, timestamp, session_date, is_premarket, is_regular_hours,
open, high, low, close, volume, vwap, quality_score
FROM minute_bars
WHERE symbol = ? AND session_date = ?
ORDER BY timestamp ASC
""",
[symbol, session_date],
).df()
def query_previous_close(con, symbol: str, session_date: date) -> float | None:
row = con.execute(
"""
SELECT close FROM daily_bars
WHERE symbol = ? AND trade_date < ?
ORDER BY trade_date DESC LIMIT 1
""",
[symbol, session_date],
).fetchone()
return float(row[0]) if row else None
def query_avg_daily_volume(con, symbol: str, session_date: date, days: int = 20) -> float | None:
row = con.execute(
"""
SELECT AVG(volume) FROM (
SELECT volume FROM daily_bars
WHERE symbol = ? AND trade_date < ?
ORDER BY trade_date DESC LIMIT ?
)
""",
[symbol, session_date, days],
).fetchone()
return float(row[0]) if row and row[0] is not None else None
+14
View File
@@ -0,0 +1,14 @@
"""Research database schema — reuses the core market schema."""
from __future__ import annotations
from pathlib import Path
from storage.db import get_connection
DEFAULT_RESEARCH_DB = "data/research/market.duckdb"
def get_research_connection(db_path: str | Path = DEFAULT_RESEARCH_DB):
"""Open (and initialize) the research market database."""
return get_connection(db_path)
+102
View File
@@ -0,0 +1,102 @@
"""Research utilities CLI.
python research_cli.py symbols --date 2026-06-11
python research_cli.py gappers --date 2026-06-11
python research_cli.py news
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import date
from dotenv import load_dotenv
from research import query as rq
from research.ingestion.rss import ingest_all_feeds
from research.ingestion.signals import scan_gappers, store_scanner_snapshot
from research.multi_schema import open_research_db
DEFAULT_FEEDS = {
"globenewswire_public": "https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies",
"prnewswire_news": "https://www.prnewswire.com/rss/news-releases-list.rss",
}
def cmd_symbols(args) -> int:
con = open_research_db("market")
rows = rq.query_session_symbols(
con, args.session_date, price_min=args.price_min,
price_max=args.price_max, limit=args.limit,
)
if not rows:
print("no symbols — ingest minute bars first (fetch_minute_bars.py)")
return 1
for r in rows:
print(f"{r['symbol']:>6} last={r['last_price']:<8.2f} bars={r['bar_count']}")
return 0
def cmd_gappers(args) -> int:
con = open_research_db("market")
candidates = scan_gappers(
con, args.session_date,
min_gap_pct=args.min_gap, min_relative_volume=args.min_rvol,
price_min=args.price_min, price_max=args.price_max,
)
if not candidates:
print("no gappers found for this session")
return 0
stored = store_scanner_snapshot(con, candidates)
for c in candidates:
print(f"#{c.rank:<2} {c.symbol:>6} gap={c.gap_pct:>6.1f}% rvol={c.relative_volume:>5.1f}x "
f"price={c.price:<8.2f} vol={c.cumulative_volume:,}")
print(f"\nstored {stored} rows in scanner_snapshots")
return 0
def cmd_news(args) -> int:
con = open_research_db("news")
feeds = dict(DEFAULT_FEEDS)
extra = os.environ.get("NEWS_RSS_FEEDS", "")
for pair in extra.split(";"):
if "=" in pair:
name, url = pair.split("=", 1)
feeds[name.strip()] = url.strip()
for result in ingest_all_feeds(con, feeds):
status = result.error or f"http {result.http_status}"
print(f"{result.source:<28} items={result.item_count:<4} new={len(result.new_items):<4} ({status})")
return 0
def main(argv: list[str] | None = None) -> int:
load_dotenv()
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
def add_common(p):
p.add_argument("--date", default=date.today().isoformat())
p.add_argument("--price-min", type=float, default=1.0)
p.add_argument("--price-max", type=float, default=20.0)
p_sym = sub.add_parser("symbols", help="list symbols with bars for a session")
add_common(p_sym)
p_sym.add_argument("--limit", type=int, default=50)
p_gap = sub.add_parser("gappers", help="scan for gappers and store snapshot")
add_common(p_gap)
p_gap.add_argument("--min-gap", type=float, default=5.0)
p_gap.add_argument("--min-rvol", type=float, default=2.0)
sub.add_parser("news", help="ingest RSS feeds into news.duckdb")
args = parser.parse_args(argv)
if hasattr(args, "date"):
args.session_date = date.fromisoformat(args.date)
return {"symbols": cmd_symbols, "gappers": cmd_gappers, "news": cmd_news}[args.command](args)
if __name__ == "__main__":
sys.exit(main())
+356
View File
@@ -0,0 +1,356 @@
"""Live paper-trading orchestrator (Milestone 4 end-to-end).
This is the piece that was missing: a single loop tying together
1. ingest — latest 1-minute Alpaca IEX bars into research market.duckdb
(plus a daily-bar backfill on startup so gap %% / RVOL work)
2. watch — Watcher.tick over the ingested bars, emitting signal_ready
3. sync — Alpaca paper account / positions / orders into the event DB
4. execute — TradingExecutionService.tick: approval requests, and order
submission (auto or via dashboard approval)
5. dashboard — optional embedded API + UI at http://127.0.0.1:8010
Usage
-----
python run_live_paper.py # continuous loop
python run_live_paper.py --once # single pass, then exit
python run_live_paper.py --symbols AAPL,SNDL --auto-approve
python run_live_paper.py --no-dashboard
Without ALPACA_API_KEY / ALPACA_SECRET_KEY the loop runs in dry mode:
network steps are skipped with clear messages, but watcher + dashboard
still work against whatever data already exists.
"""
from __future__ import annotations
import argparse
import os
import sys
import time
import uuid
from datetime import datetime, timezone
from dotenv import load_dotenv
from alpaca_paper.client import AlpacaPaperClient
from alpaca_paper.execution import AlpacaPaperExecutor
from alpaca_paper.settings import AlpacaPaperSettings
from alpaca_paper.sync import AlpacaPaperSync
from api.main import DashboardState, serve_in_background
from research.ingestion.market_data import (
classify_session,
discover_active_symbols,
ingest_daily_history,
ingest_live_minute_bars,
)
from research.ingestion.scheduler import Scheduler
from research.ingestion.watcher_task import ResearchWatchlistProvider
from research.multi_schema import open_research_db
from runtime.watcher import Watcher, WatcherConfig
from storage.event_schema import EventMode
from storage.event_store import EventStore
from trading_execution import ExecutionSettings, TradingExecutionService
from trading_mode import TradingModeSettings
DEFAULT_SYMBOLS = ["AAPL", "TSLA", "AMD", "NVDA", "SNDL"]
def _flag(name: str, default: str = "1") -> bool:
return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"}
def _now_session_date():
session_date, _, _, _ = classify_session(datetime.now(timezone.utc))
return session_date
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Momentum live paper-trading loop")
parser.add_argument("--once", action="store_true", help="run one pass and exit")
parser.add_argument(
"--symbols",
default=os.environ.get("WATCHER_SYMBOLS", ""),
help="comma-separated symbols to watch (default: WATCHER_SYMBOLS env or built-ins)",
)
parser.add_argument(
"--auto-approve",
action="store_true",
help="auto-approve and submit ready signals (otherwise approve from the dashboard)",
)
parser.add_argument(
"--discover",
action="store_true",
help="also pull symbols from Alpaca's most-actives screener",
)
parser.add_argument("--no-dashboard", action="store_true", help="skip the embedded dashboard")
return parser.parse_args(argv)
def build_runtime(args: argparse.Namespace) -> dict:
"""Wire up every component; safe without API keys."""
load_dotenv()
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
if not symbols:
symbols = DEFAULT_SYMBOLS
alpaca_settings = AlpacaPaperSettings.from_env()
has_keys = alpaca_settings.is_configured
client = AlpacaPaperClient(alpaca_settings) if has_keys else None
event_db = os.environ.get("WATCHER_EVENT_DB_PATH", "./data/momentum.duckdb")
os.makedirs(os.path.dirname(event_db) or ".", exist_ok=True)
store = EventStore(event_db)
research_con = open_research_db("market")
session_id = f"paper-{_now_session_date().isoformat()}-{uuid.uuid4().hex[:6]}"
watcher = Watcher(
store,
ResearchWatchlistProvider(
research_con,
limit=int(os.environ.get("WATCHER_MAX_SYMBOLS", "25")),
),
WatcherConfig(
session_id=session_id,
mode=EventMode.PAPER,
max_symbols=int(os.environ.get("WATCHER_MAX_SYMBOLS", "25")),
min_bars=int(os.environ.get("WATCHER_MIN_BARS", "10")),
min_quality=float(os.environ.get("WATCHER_MIN_QUALITY", "0.30")),
),
)
exec_settings = ExecutionSettings.from_env()
if args.auto_approve:
exec_settings.auto_approve = True
executor = AlpacaPaperExecutor(store, client=client, session_id=session_id) if client else None
# Live last-trade price source for entry invalidation — tick-by-tick, not
# bar close. Pulls the most recent trade from Alpaca's data API on demand;
# a tiny TTL cache collapses redundant lookups within a single guard pass.
# On any failure it returns None so the price-break check is simply skipped
# for that pass (the wall-clock timeout still protects the entry) rather
# than silently falling back to a stale bar close.
_price_cache: dict[str, tuple[float, float]] = {}
_PRICE_TTL = 2.0
def latest_price(symbol: str):
if client is None:
return None
import time as _time
cached = _price_cache.get(symbol)
now = _time.monotonic()
if cached and (now - cached[1]) < _PRICE_TTL:
return cached[0]
try:
trades = client.get_latest_trades([symbol])
trade = trades.get(symbol) or trades.get(symbol.upper())
price = float(trade["p"]) if trade and trade.get("p") is not None else None
except Exception:
price = None
if price is not None:
_price_cache[symbol] = (price, now)
return price
execution = (
TradingExecutionService(
store,
executor=executor,
settings=exec_settings,
trading_mode=TradingModeSettings.from_env(),
session_id=session_id,
price_provider=latest_price,
)
if executor
else None
)
sync = AlpacaPaperSync(store, client=client, session_id=session_id) if client else None
return {
"symbols": symbols,
"client": client,
"has_keys": has_keys,
"store": store,
"research_con": research_con,
"watcher": watcher,
"execution": execution,
"sync": sync,
"session_id": session_id,
"event_db": event_db,
}
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
rt = build_runtime(args)
symbols: list[str] = rt["symbols"]
client = rt["client"]
print(f"[boot] session={rt['session_id']} event_db={rt['event_db']}")
print(f"[boot] watching: {', '.join(symbols)}")
if not rt["has_keys"]:
print("[boot] no ALPACA_API_KEY/SECRET — dry mode: ingestion, sync and "
"order submission are skipped; watcher runs on existing data")
# dashboard
server = None
if not args.no_dashboard and _flag("DASHBOARD_ENABLED", "1"):
host = os.environ.get("DASHBOARD_HOST", "127.0.0.1")
port = int(os.environ.get("DASHBOARD_PORT", "8010"))
def inject_symbol(symbol: str) -> None:
# add to the watcher's provider so it's evaluated next tick, and
# to the live symbol list so the next ingest pass pulls its bars
provider = rt["watcher"].provider
if hasattr(provider, "add_symbol"):
provider.add_symbol(symbol)
if symbol not in symbols:
symbols.append(symbol)
state = DashboardState(
rt["event_db"],
execution_service=rt["execution"],
execution_mode=TradingModeSettings.from_env().execution_mode,
research_con=rt["research_con"],
watch_inject=inject_symbol,
)
try:
server, _ = serve_in_background(state, host, port)
print(f"[boot] dashboard at http://{host}:{port}")
except OSError as exc:
print(f"[boot] dashboard disabled ({exc})")
# startup: daily history backfill so gap%/RVOL have a baseline
if client:
if args.discover:
discovered = discover_active_symbols(
client,
top=10,
price_min=float(os.environ.get("WATCHER_PRICE_MIN", "1")),
price_max=float(os.environ.get("WATCHER_PRICE_MAX", "20")),
)
if discovered:
print(f"[boot] screener added: {', '.join(discovered)}")
symbols = sorted(set(symbols) | set(discovered))
else:
print("[boot] screener unavailable — using static symbol list")
daily = ingest_daily_history(rt["research_con"], client, symbols, days=30)
print(f"[boot] daily backfill: {daily.daily_rows} rows"
+ (f" (errors: {daily.errors})" if daily.errors else ""))
lookback = int(os.environ.get("LIVE_BARS_LOOKBACK_MINUTES", "240"))
def step_ingest():
if not client:
return "skipped (no keys)"
res = ingest_live_minute_bars(rt["research_con"], client, symbols, lookback_minutes=lookback)
return f"{res.minute_rows} minute rows" + (f", errors={res.errors}" if res.errors else "")
def step_watch():
res = rt["watcher"].tick(_now_session_date())
return (f"evaluated={res.evaluated} ready={res.ready} blocked={len(res.blocked)}"
+ (f" errors={res.errors}" if res.errors else ""))
def step_sync():
if not rt["sync"]:
return "skipped (no keys)"
rt["sync"].sync_all()
return "ok"
def step_execute():
if not rt["execution"]:
return "skipped (no keys)"
res = rt["execution"].tick()
backed = len(res.get("backed_out") or [])
line = (f"approvals_requested={res['approvals_requested']} "
f"auto_executed={len(res['auto_executed'])}")
if backed:
syms = ", ".join(b["symbol"] for b in res["backed_out"])
line += f" backed_out={backed} ({syms})"
return line
def step_guard():
# Fast invalidation watch: re-check armed (unfilled) entries against the
# live last-trade price and the wall-clock timeout. Runs far more often
# than `execute` so a break of the entry trigger is caught tick-by-tick
# instead of waiting for the next execute pass.
if not rt["execution"]:
return None
backed = rt["execution"].expire_stale_entries()
if not backed:
return None
return "backed_out " + ", ".join(
f"{b['symbol']} ({b['reason']})" for b in backed
)
scheduler = Scheduler()
scheduler.add("ingest", step_ingest, float(os.environ.get("LIVE_BARS_INTERVAL_SECONDS", "60")),
enabled=_flag("LIVE_BARS_ENABLED", "1"))
scheduler.add("watch", step_watch, float(os.environ.get("WATCHER_INTERVAL_SECONDS", "30")),
enabled=_flag("WATCHER_ENABLED", "1"))
scheduler.add("sync", step_sync, float(os.environ.get("ALPACA_PAPER_SYNC_INTERVAL_SECONDS", "60")),
enabled=_flag("ALPACA_PAPER_SYNC_ENABLED", "1"))
scheduler.add("execute", step_execute, float(os.environ.get("TRADING_EXECUTION_INTERVAL_SECONDS", "30")),
enabled=_flag("TRADING_EXECUTION_ENABLED", "1"))
scheduler.add("guard", step_guard, float(os.environ.get("TRADING_ENTRY_GUARD_INTERVAL_SECONDS", "5")),
enabled=_flag("TRADING_EXECUTION_ENABLED", "1"))
def run_pass():
# force every task due, in pipeline order
for task in scheduler.tasks:
task.last_run = -10**9
results = {}
for name in ("ingest", "watch", "sync", "execute"):
for task in scheduler.tasks:
if task.name == name and task.enabled:
results.update(scheduler_run_one(task))
stamp = datetime.now().strftime("%H:%M:%S")
line = " | ".join(f"{k}: {v}" for k, v in results.items())
print(f"[{stamp}] {line}")
def scheduler_run_one(task):
task.last_run = time.monotonic()
task.run_count += 1
try:
out = task.func()
task.last_error = None
return {task.name: out}
except Exception as exc: # noqa: BLE001
task.error_count += 1
task.last_error = str(exc)
return {task.name: f"ERROR {exc}"}
if args.once:
run_pass()
if server:
server.shutdown()
return 0
print("[boot] entering live loop (Ctrl-C to stop)")
try:
# one immediate full pass, then interval-driven
run_pass()
while True:
results = scheduler.run_pending()
if results:
stamp = datetime.now().strftime("%H:%M:%S")
line = " | ".join(
f"{k}: {v if not isinstance(v, Exception) else f'ERROR {v}'}"
for k, v in results.items()
)
print(f"[{stamp}] {line}")
time.sleep(1.0)
except KeyboardInterrupt:
print("\n[stop] shutting down")
finally:
if server:
server.shutdown()
rt["store"].close()
rt["research_con"].close()
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
"""Runtime package: intraday watcher and provider registry (Milestone 4)."""
+10
View File
@@ -0,0 +1,10 @@
"""Market-data provider registry."""
from runtime.providers.registry import (
ProviderKind,
ProviderConfig,
ProviderRegistry,
default_registry,
)
__all__ = ["ProviderKind", "ProviderConfig", "ProviderRegistry", "default_registry"]
+112
View File
@@ -0,0 +1,112 @@
"""Provider registry for market-data sources.
(Repaired: ProviderConfig is a proper top-level dataclass and the registry
initializes its mapping correctly — the original nested the dataclass inside
the enum and used an invalid `self.providers: {}` annotation-as-assignment.)
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from enum import Enum
class ProviderKind(str, Enum):
"""Supported market-data providers."""
ALPACA = "alpaca"
SCHWAB = "schwab"
POLYGON = "polygon"
FINNHUB = "finnhub"
SYNTHETIC = "synthetic"
@dataclass
class ProviderConfig:
"""Configuration for one provider."""
kind: ProviderKind
enabled: bool = True
priority: int = 100 # lower = preferred
rate_limit_per_minute: int = 200
supports_minute_bars: bool = True
supports_quotes: bool = True
env_keys: tuple[str, ...] = field(default_factory=tuple)
def is_configured(self, env: dict[str, str] | None = None) -> bool:
"""A provider is configured when all of its env keys are present."""
values = dict(os.environ)
if env is not None:
values.update(env)
return all(values.get(key) for key in self.env_keys)
class ProviderRegistry:
"""Registry of provider configs with priority-based selection."""
def __init__(self) -> None:
self.providers: dict[ProviderKind, ProviderConfig] = {}
def register(self, config: ProviderConfig) -> None:
self.providers[config.kind] = config
def get(self, kind: ProviderKind | str) -> ProviderConfig | None:
if isinstance(kind, str):
try:
kind = ProviderKind(kind)
except ValueError:
return None
return self.providers.get(kind)
def enabled_providers(self) -> list[ProviderConfig]:
return sorted(
(p for p in self.providers.values() if p.enabled),
key=lambda p: p.priority,
)
def best_configured(
self, env: dict[str, str] | None = None
) -> ProviderConfig | None:
for provider in self.enabled_providers():
if provider.is_configured(env):
return provider
return None
def default_registry() -> ProviderRegistry:
"""Registry with the standard provider stack."""
registry = ProviderRegistry()
registry.register(
ProviderConfig(
kind=ProviderKind.ALPACA,
priority=10,
env_keys=("ALPACA_API_KEY", "ALPACA_SECRET_KEY"),
)
)
registry.register(
ProviderConfig(
kind=ProviderKind.SCHWAB,
priority=20,
env_keys=("SCHWAB_MARKET_DATA_APP_KEY", "SCHWAB_MARKET_DATA_APP_SECRET"),
)
)
registry.register(
ProviderConfig(
kind=ProviderKind.POLYGON, priority=30, env_keys=("POLYGON_API_KEY",)
)
)
registry.register(
ProviderConfig(
kind=ProviderKind.FINNHUB, priority=40, env_keys=("FINNHUB_API_KEY",)
)
)
registry.register(
ProviderConfig(
kind=ProviderKind.SYNTHETIC,
priority=1000,
env_keys=(),
supports_quotes=False,
)
)
return registry
+274
View File
@@ -0,0 +1,274 @@
"""Intraday watcher (Milestone 4).
Pulls candidates from a watchlist provider, evaluates each with the pure
strategy engine, drives a per-symbol state machine, and emits canonical
events into the event store:
discovered -> watching -> ready | blocked (re-evaluated each tick)
Signals are debounced: a symbol emits signal_ready at most once per session
unless it falls back to blocked first.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, date
from typing import Protocol
import pandas as pd
from storage.event_schema import (
CriteriaEvaluatedEvent,
EventMode,
SignalBlockedEvent,
SignalReadyEvent,
SymbolDiscoveredEvent,
SymbolStateChangedEvent,
)
from storage.event_store import EventStore
from strategy.evaluation.setup_evaluator import evaluate_setup
logger = logging.getLogger(__name__)
@dataclass
class WatchCandidate:
"""One symbol the watcher should evaluate."""
symbol: str
last_price: float | None = None
previous_close: float | None = None
avg_daily_volume: float | None = None
source: str = "research"
class WatchlistProvider(Protocol):
"""Source of candidates and their session bars."""
def get_candidates(self, session_date: date) -> list[WatchCandidate]: ...
def get_bars(self, symbol: str, session_date: date) -> pd.DataFrame: ...
@dataclass
class WatcherConfig:
session_id: str = ""
mode: EventMode = EventMode.PAPER
max_symbols: int = 25
min_bars: int = 10
ready_score_pct: float = 60.0
min_quality: float = 0.30
@dataclass
class WatcherTickResult:
session_date: str
evaluated: int = 0
ready: list[str] = field(default_factory=list)
blocked: list[str] = field(default_factory=list)
discovered: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
class Watcher:
"""Event-emitting intraday watcher."""
def __init__(
self,
store: EventStore,
provider: WatchlistProvider,
config: WatcherConfig | None = None,
):
self.store = store
self.provider = provider
self.config = config or WatcherConfig()
if not self.config.session_id:
self.config.session_id = f"session-{date.today().isoformat()}"
# symbol -> current state for this session
self._states: dict[str, str] = {}
# -- state helpers -------------------------------------------------
def _transition(self, symbol: str, new_state: str, reason: str | None) -> None:
previous = self._states.get(symbol)
if previous == new_state:
return
self._states[symbol] = new_state
self.store.emit(
SymbolStateChangedEvent(
timestamp=datetime.now(),
mode=self.config.mode,
correlation_id=self.config.session_id,
message=f"{symbol}: {previous or 'new'} -> {new_state}"
+ (f" ({reason})" if reason else ""),
symbol=symbol,
previous_state=previous,
new_state=new_state,
state_reason=reason,
payload={"symbol": symbol, "previous_state": previous,
"new_state": new_state, "state_reason": reason},
)
)
def _discover(self, candidate: WatchCandidate) -> None:
if candidate.symbol in self._states:
return
self.store.emit(
SymbolDiscoveredEvent(
timestamp=datetime.now(),
mode=self.config.mode,
correlation_id=self.config.session_id,
message=f"Discovered {candidate.symbol} via {candidate.source}",
symbol=candidate.symbol,
symbol_data={
"last_price": candidate.last_price,
"previous_close": candidate.previous_close,
"avg_daily_volume": candidate.avg_daily_volume,
"source": candidate.source,
},
payload={"symbol": candidate.symbol, "source": candidate.source},
)
)
self._transition(candidate.symbol, "watching", "added to watchlist")
# -- main loop body ------------------------------------------------
def tick(self, session_date: date | None = None) -> WatcherTickResult:
"""One evaluation pass over all candidates."""
session_date = session_date or date.today()
result = WatcherTickResult(session_date=session_date.isoformat())
try:
candidates = self.provider.get_candidates(session_date)
except Exception as exc: # provider failure should not kill the loop
logger.exception("watchlist provider failed")
result.errors.append(f"provider: {exc}")
return result
for candidate in candidates[: self.config.max_symbols]:
symbol = candidate.symbol
try:
if symbol not in self._states:
self._discover(candidate)
result.discovered.append(symbol)
bars = self.provider.get_bars(symbol, session_date)
if bars is None or len(bars) < self.config.min_bars:
self._transition(
symbol, "watching",
f"waiting for data ({0 if bars is None else len(bars)} bars)",
)
continue
# Evaluate the setup as of the latest bar's timestamp, not the
# wall clock. Minute bars are stored UTC-naive, but the entry
# cutoff reasons in US/Eastern wall-clock, so convert. This
# keeps replay and backfilled data honest (a 09:40 ET bar is
# judged against the 09:40 cutoff regardless of run time).
eval_time = None
try:
from datetime import timezone as _tz
from zoneinfo import ZoneInfo as _ZI
last_ts = pd.Timestamp(bars["timestamp"].iloc[-1]).to_pydatetime()
if last_ts.tzinfo is None:
last_ts = last_ts.replace(tzinfo=_tz.utc)
eval_time = last_ts.astimezone(_ZI("America/New_York")).replace(
tzinfo=None
)
except Exception: # noqa: BLE001 - fall back to now()
eval_time = None
evaluation = evaluate_setup(
bars,
previous_close=candidate.previous_close,
avg_daily_volume=candidate.avg_daily_volume,
ready_score_pct=self.config.ready_score_pct,
evaluation_time=eval_time,
)
result.evaluated += 1
self.store.emit(
CriteriaEvaluatedEvent(
timestamp=datetime.now(),
mode=self.config.mode,
correlation_id=self.config.session_id,
message=(
f"{symbol} criteria {evaluation.criteria_passed}/"
f"{evaluation.criteria_total} "
f"({evaluation.success_score_pct:.0f}%)"
),
symbol=symbol,
criteria_results={
"passed": evaluation.criteria_names_passed,
"failed": evaluation.criteria_names_failed,
},
total_criteria=evaluation.criteria_total,
passed_criteria=evaluation.criteria_passed,
success_score_pct=evaluation.success_score_pct,
payload={
"symbol": symbol,
"success_score_pct": evaluation.success_score_pct,
"status": evaluation.status,
},
)
)
if evaluation.status == "ready" and evaluation.setups:
result.ready.append(symbol)
if self._states.get(symbol) != "ready":
setup = evaluation.setups[0]
self._transition(symbol, "ready", setup["setup_type"])
self.store.emit(
SignalReadyEvent(
timestamp=datetime.now(),
mode=self.config.mode,
correlation_id=self.config.session_id,
message=(
f"{symbol} READY {setup['setup_type']} "
f"entry {setup['entry_price']} stop "
f"{setup['stop_loss_price']}"
),
symbol=symbol,
signal_type=setup["setup_type"],
confidence=setup["confidence"],
signal_data=setup,
payload={
"symbol": symbol,
"signal_type": setup["setup_type"],
"confidence": setup["confidence"],
"signal_data": setup,
},
)
)
else:
status = "blocked" if evaluation.status != "late" else "late"
result.blocked.append(symbol)
if self._states.get(symbol) == "ready":
# fell out of ready — allow re-signal later
self._transition(symbol, status, evaluation.reason)
elif self._states.get(symbol) != status:
self._transition(symbol, status, evaluation.reason)
self.store.emit(
SignalBlockedEvent(
timestamp=datetime.now(),
mode=self.config.mode,
correlation_id=self.config.session_id,
message=f"{symbol} blocked: {evaluation.reason}",
symbol=symbol,
blocking_reason=evaluation.reason or "unknown",
unmet_criteria=evaluation.criteria_names_failed,
payload={
"symbol": symbol,
"blocking_reason": evaluation.reason,
"unmet_criteria": evaluation.criteria_names_failed,
},
)
)
except Exception as exc:
logger.exception("watcher tick failed for %s", symbol)
result.errors.append(f"{symbol}: {exc}")
return result
+5
View File
@@ -0,0 +1,5 @@
"""Schwab broker integration (Milestone 3): auth, market data, account sync."""
from schwab.settings import SchwabSettings
__all__ = ["SchwabSettings"]
+7
View File
@@ -0,0 +1,7 @@
"""Schwab OAuth2 authentication."""
from schwab.auth.token_store import TokenBundle, TokenStore
from schwab.auth.oauth2_flow_manager import OAuth2FlowManager
from schwab.auth.lifecycle import TokenLifecycle
__all__ = ["TokenBundle", "TokenStore", "OAuth2FlowManager", "TokenLifecycle"]
+50
View File
@@ -0,0 +1,50 @@
"""Token lifecycle: load, refresh when stale, surface auth status."""
from __future__ import annotations
import logging
from schwab.auth.oauth2_flow_manager import OAuth2FlowManager
from schwab.auth.token_store import TokenBundle, TokenStore
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
class TokenLifecycle:
def __init__(
self,
settings: SchwabSettings | None = None,
token_store: TokenStore | None = None,
):
self.settings = settings or SchwabSettings.from_env()
self.token_store = token_store or TokenStore(self.settings.token_path)
self.flow = OAuth2FlowManager(self.settings, self.token_store)
def status(self) -> dict:
bundle = self.token_store.load()
if bundle is None:
return {"authenticated": False, "reason": "no token file"}
if bundle.is_expired and not bundle.refresh_token:
return {"authenticated": False, "reason": "token expired, no refresh token"}
return {
"authenticated": True,
"expired": bundle.is_expired,
"expires_at": bundle.expires_at,
}
def get_access_token(self) -> str | None:
"""Return a valid access token, refreshing if needed. None if unauth."""
bundle = self.token_store.load()
if bundle is None:
return None
if not bundle.is_expired:
return bundle.access_token
if not bundle.refresh_token:
return None
try:
refreshed: TokenBundle = self.flow.refresh(bundle.refresh_token)
return refreshed.access_token
except Exception:
logger.exception("schwab token refresh failed")
return None
+81
View File
@@ -0,0 +1,81 @@
"""Schwab OAuth2 authorization-code flow."""
from __future__ import annotations
import base64
import json
import logging
import urllib.parse
import urllib.request
from schwab.auth.token_store import TokenBundle, TokenStore
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
AUTH_URL = "https://api.schwabapi.com/v1/oauth/authorize"
TOKEN_URL = "https://api.schwabapi.com/v1/oauth/token"
class OAuth2FlowManager:
def __init__(
self,
settings: SchwabSettings | None = None,
token_store: TokenStore | None = None,
use_broker_app: bool = True,
):
self.settings = settings or SchwabSettings.from_env()
self.token_store = token_store or TokenStore(self.settings.token_path)
if use_broker_app and self.settings.has_broker_credentials:
self.app_key = self.settings.broker_app_key
self.app_secret = self.settings.broker_app_secret
else:
self.app_key = self.settings.market_data_app_key
self.app_secret = self.settings.market_data_app_secret
def build_authorization_url(self, state: str = "momentum") -> str:
params = urllib.parse.urlencode(
{
"client_id": self.app_key,
"redirect_uri": self.settings.redirect_uri,
"response_type": "code",
"state": state,
}
)
return f"{AUTH_URL}?{params}"
def _basic_auth_header(self) -> str:
raw = f"{self.app_key}:{self.app_secret}".encode()
return "Basic " + base64.b64encode(raw).decode()
def _token_request(self, form: dict) -> TokenBundle:
data = urllib.parse.urlencode(form).encode()
req = urllib.request.Request(
TOKEN_URL,
data=data,
headers={
"Authorization": self._basic_auth_header(),
"Content-Type": "application/x-www-form-urlencoded",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=20) as resp:
payload = json.loads(resp.read().decode())
bundle = TokenBundle.from_oauth_response(payload)
self.token_store.save(bundle)
return bundle
def exchange_code(self, authorization_code: str) -> TokenBundle:
"""Exchange the redirect ?code= for access + refresh tokens."""
return self._token_request(
{
"grant_type": "authorization_code",
"code": urllib.parse.unquote(authorization_code),
"redirect_uri": self.settings.redirect_uri,
}
)
def refresh(self, refresh_token: str) -> TokenBundle:
return self._token_request(
{"grant_type": "refresh_token", "refresh_token": refresh_token}
)
+71
View File
@@ -0,0 +1,71 @@
"""Token persistence for Schwab OAuth2 tokens.
SECURITY: token files contain live credentials. They are written 0600 and
data/schwab_tokens.json is git-ignored. Never commit token files.
"""
from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass, asdict
from pathlib import Path
@dataclass
class TokenBundle:
access_token: str = ""
refresh_token: str = ""
expires_at: float = 0.0 # unix epoch seconds
token_type: str = "Bearer"
scope: str = ""
@property
def is_expired(self) -> bool:
return not self.access_token or time.time() >= self.expires_at - 60
@classmethod
def from_oauth_response(cls, payload: dict) -> "TokenBundle":
return cls(
access_token=payload.get("access_token", ""),
refresh_token=payload.get("refresh_token", ""),
expires_at=time.time() + float(payload.get("expires_in", 1800)),
token_type=payload.get("token_type", "Bearer"),
scope=payload.get("scope", ""),
)
class TokenStore:
def __init__(self, path: str | Path = "data/schwab_tokens.json"):
self.path = Path(path)
def exists(self) -> bool:
return self.path.exists()
def load(self) -> TokenBundle | None:
if not self.path.exists():
return None
try:
data = json.loads(self.path.read_text())
except (json.JSONDecodeError, OSError):
return None
return TokenBundle(
access_token=data.get("access_token", ""),
refresh_token=data.get("refresh_token", ""),
expires_at=float(data.get("expires_at", 0)),
token_type=data.get("token_type", "Bearer"),
scope=data.get("scope", ""),
)
def save(self, bundle: TokenBundle) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(asdict(bundle), indent=2))
try:
os.chmod(self.path, 0o600)
except OSError:
pass
def clear(self) -> None:
if self.path.exists():
self.path.unlink()
+6
View File
@@ -0,0 +1,6 @@
"""Schwab broker health."""
from schwab.health.models import HealthStatus, HealthCheck, HealthReport
from schwab.health.reporter import HealthReporter
__all__ = ["HealthStatus", "HealthCheck", "HealthReport", "HealthReporter"]
+37
View File
@@ -0,0 +1,37 @@
"""Broker health models."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class HealthStatus(str, Enum):
HEALTHY = "healthy"
DEGRADED = "degraded"
DOWN = "down"
UNAUTHENTICATED = "unauthenticated"
@dataclass
class HealthCheck:
name: str
status: HealthStatus
detail: str | None = None
@dataclass
class HealthReport:
broker_name: str
status: HealthStatus
checks: list[HealthCheck] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"broker_name": self.broker_name,
"status": self.status.value,
"checks": [
{"name": c.name, "status": c.status.value, "detail": c.detail}
for c in self.checks
],
}
+96
View File
@@ -0,0 +1,96 @@
"""Schwab broker health reporter — emits broker_health_changed events."""
from __future__ import annotations
import logging
from datetime import datetime
from schwab.auth.lifecycle import TokenLifecycle
from schwab.health.models import HealthCheck, HealthReport, HealthStatus
from schwab.settings import SchwabSettings
from storage.event_schema import BrokerHealthChangedEvent, EventMode
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
class HealthReporter:
def __init__(
self,
store: EventStore | None = None,
settings: SchwabSettings | None = None,
lifecycle: TokenLifecycle | None = None,
session_id: str | None = None,
):
self.store = store
self.settings = settings or SchwabSettings.from_env()
self.lifecycle = lifecycle or TokenLifecycle(self.settings)
self.session_id = session_id
self._last_status: HealthStatus | None = None
def check(self) -> HealthReport:
checks: list[HealthCheck] = []
if self.settings.has_broker_credentials or self.settings.has_market_data_credentials:
checks.append(HealthCheck("credentials", HealthStatus.HEALTHY))
else:
checks.append(
HealthCheck(
"credentials",
HealthStatus.DOWN,
"no SCHWAB_* app keys configured",
)
)
auth = self.lifecycle.status()
if auth.get("authenticated") and not auth.get("expired"):
checks.append(HealthCheck("token", HealthStatus.HEALTHY))
elif auth.get("authenticated"):
checks.append(
HealthCheck("token", HealthStatus.DEGRADED, "token expired; refresh pending")
)
else:
checks.append(
HealthCheck(
"token", HealthStatus.UNAUTHENTICATED,
str(auth.get("reason") or "not authenticated"),
)
)
statuses = {c.status for c in checks}
if statuses == {HealthStatus.HEALTHY}:
overall = HealthStatus.HEALTHY
elif HealthStatus.DOWN in statuses:
overall = HealthStatus.DOWN
elif HealthStatus.UNAUTHENTICATED in statuses:
overall = HealthStatus.UNAUTHENTICATED
else:
overall = HealthStatus.DEGRADED
report = HealthReport(broker_name="schwab", status=overall, checks=checks)
self._maybe_emit(report)
return report
def _maybe_emit(self, report: HealthReport) -> None:
if self.store is None or report.status == self._last_status:
self._last_status = report.status
return
try:
self.store.emit(
BrokerHealthChangedEvent(
timestamp=datetime.now(),
mode=EventMode.LIVE,
correlation_id=self.session_id,
message=f"Schwab health: {report.status.value}",
broker_name="schwab",
previous_health=self._last_status.value
if self._last_status
else "unknown",
new_health=report.status.value,
health_reason="; ".join(
f"{c.name}={c.status.value}" for c in report.checks
),
payload=report.to_dict(),
)
)
except Exception:
logger.exception("failed to emit broker health event")
self._last_status = report.status
+24
View File
@@ -0,0 +1,24 @@
"""Schwab market data."""
from schwab.market.models import Quote, Candle, PriceHistory, OptionQuote
from schwab.market.client import SchwabMarketClient, SchwabApiError
from schwab.market.adapter import (
quotes_from_payload,
price_history_to_dataframe,
payload_to_dataframe,
)
from schwab.market.option_chain_service import OptionChainService, OptionChainResult
__all__ = [
"Quote",
"Candle",
"PriceHistory",
"OptionQuote",
"SchwabMarketClient",
"SchwabApiError",
"quotes_from_payload",
"price_history_to_dataframe",
"payload_to_dataframe",
"OptionChainService",
"OptionChainResult",
]
+34
View File
@@ -0,0 +1,34 @@
"""Adapter: Schwab market payloads -> typed models / DataFrames."""
from __future__ import annotations
from datetime import datetime, timezone
import pandas as pd
from schwab.market.models import PriceHistory, Quote
def quotes_from_payload(payload: dict) -> list[Quote]:
return [Quote.from_api(symbol, body) for symbol, body in payload.items()]
def price_history_to_dataframe(history: PriceHistory) -> pd.DataFrame:
rows = [
{
"timestamp": datetime.fromtimestamp(
c.datetime_ms / 1000, tz=timezone.utc
).replace(tzinfo=None),
"open": c.open,
"high": c.high,
"low": c.low,
"close": c.close,
"volume": c.volume,
}
for c in history.candles
]
return pd.DataFrame(rows)
def payload_to_dataframe(payload: dict) -> pd.DataFrame:
return price_history_to_dataframe(PriceHistory.from_api(payload))
+83
View File
@@ -0,0 +1,83 @@
"""Schwab market-data HTTP client (quotes + price history)."""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from schwab.auth.lifecycle import TokenLifecycle
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
class SchwabApiError(RuntimeError):
def __init__(self, status: int, body: str):
super().__init__(f"Schwab API error {status}: {body[:300]}")
self.status = status
self.body = body
class SchwabMarketClient:
def __init__(
self,
settings: SchwabSettings | None = None,
lifecycle: TokenLifecycle | None = None,
timeout: int = 15,
):
self.settings = settings or SchwabSettings.from_env()
self.lifecycle = lifecycle or TokenLifecycle(self.settings)
self.timeout = timeout
def _get(self, path: str, params: dict | None = None) -> dict:
token = self.lifecycle.get_access_token()
if not token:
raise SchwabApiError(401, "no valid Schwab access token")
url = f"{self.settings.api_base_url}{path}"
if params:
url += "?" + urllib.parse.urlencode(
{k: v for k, v in params.items() if v is not None}
)
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {token}"}
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as exc:
raise SchwabApiError(exc.code, exc.read().decode(errors="replace")) from exc
def get_quotes(self, symbols: list[str]) -> dict:
return self._get(
"/marketdata/v1/quotes", params={"symbols": ",".join(symbols)}
)
def get_price_history(
self,
symbol: str,
period_type: str = "day",
period: int = 1,
frequency_type: str = "minute",
frequency: int = 1,
need_extended_hours: bool = True,
) -> dict:
return self._get(
"/marketdata/v1/pricehistory",
params={
"symbol": symbol,
"periodType": period_type,
"period": period,
"frequencyType": frequency_type,
"frequency": frequency,
"needExtendedHoursData": str(need_extended_hours).lower(),
},
)
def get_option_chain(self, symbol: str, contract_type: str = "ALL") -> dict:
return self._get(
"/marketdata/v1/chains",
params={"symbol": symbol, "contractType": contract_type},
)
+86
View File
@@ -0,0 +1,86 @@
"""Schwab market-data models.
(Repaired: the original had broken dataclass indentation, a `string` type
annotation, and an invalid `int = None = None` default chain.)
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Quote:
symbol: str
bid_price: float | None = None
ask_price: float | None = None
last_price: float | None = None
bid_size: int | None = None
ask_size: int | None = None
total_volume: int | None = None
quote_time: str | None = None
@classmethod
def from_api(cls, symbol: str, payload: dict) -> "Quote":
quote = payload.get("quote") or payload
return cls(
symbol=symbol,
bid_price=quote.get("bidPrice"),
ask_price=quote.get("askPrice"),
last_price=quote.get("lastPrice"),
bid_size=quote.get("bidSize"),
ask_size=quote.get("askSize"),
total_volume=quote.get("totalVolume"),
quote_time=str(quote.get("quoteTime")) if quote.get("quoteTime") else None,
)
@dataclass
class Candle:
open: float
high: float
low: float
close: float
volume: int
datetime_ms: int
@classmethod
def from_api(cls, payload: dict) -> "Candle":
return cls(
open=float(payload.get("open") or 0),
high=float(payload.get("high") or 0),
low=float(payload.get("low") or 0),
close=float(payload.get("close") or 0),
volume=int(payload.get("volume") or 0),
datetime_ms=int(payload.get("datetime") or 0),
)
@dataclass
class PriceHistory:
symbol: str
candles: list[Candle] = field(default_factory=list)
empty: bool = True
@classmethod
def from_api(cls, payload: dict) -> "PriceHistory":
candles = [Candle.from_api(c) for c in payload.get("candles") or []]
return cls(
symbol=payload.get("symbol", ""),
candles=candles,
empty=payload.get("empty", not candles),
)
@dataclass
class OptionQuote:
symbol: str
strike: float | None = None
expiration: str | None = None
put_call: str | None = None
bid: float | None = None
ask: float | None = None
last: float | None = None
delta: float | None = None
open_interest: int | None = None
volume: int | None = None
+63
View File
@@ -0,0 +1,63 @@
"""Option chain retrieval service.
(Repaired: original had dataclass default-ordering errors and unbalanced
parentheses.)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from schwab.market.client import SchwabApiError, SchwabMarketClient
from schwab.market.models import OptionQuote
logger = logging.getLogger(__name__)
@dataclass
class OptionChainResult:
symbol: str
underlying_price: float | None = None
calls: list[OptionQuote] = field(default_factory=list)
puts: list[OptionQuote] = field(default_factory=list)
error: str | None = None
class OptionChainService:
def __init__(self, client: SchwabMarketClient | None = None):
self.client = client or SchwabMarketClient()
def get_chain(self, symbol: str) -> OptionChainResult:
try:
payload = self.client.get_option_chain(symbol)
except SchwabApiError as exc:
return OptionChainResult(symbol=symbol, error=str(exc))
result = OptionChainResult(
symbol=symbol, underlying_price=payload.get("underlyingPrice")
)
result.calls = self._parse_side(payload.get("callExpDateMap") or {}, "CALL")
result.puts = self._parse_side(payload.get("putExpDateMap") or {}, "PUT")
return result
@staticmethod
def _parse_side(exp_map: dict, put_call: str) -> list[OptionQuote]:
quotes: list[OptionQuote] = []
for expiration, strikes in exp_map.items():
for strike, contracts in (strikes or {}).items():
for contract in contracts or []:
quotes.append(
OptionQuote(
symbol=contract.get("symbol", ""),
strike=float(strike),
expiration=expiration.split(":")[0],
put_call=put_call,
bid=contract.get("bid"),
ask=contract.get("ask"),
last=contract.get("last"),
delta=contract.get("delta"),
open_interest=contract.get("openInterest"),
volume=contract.get("totalVolume"),
)
)
return quotes
+6
View File
@@ -0,0 +1,6 @@
"""Schwab orders (read-only)."""
from schwab.orders.reader import OrdersReader
from schwab.orders.sync import OrderSync
__all__ = ["OrdersReader", "OrderSync"]
+67
View File
@@ -0,0 +1,67 @@
"""Read Schwab orders (read-only, with unauthenticated fallback)."""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from schwab.auth.lifecycle import TokenLifecycle
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
class OrdersReader:
def __init__(
self,
settings: SchwabSettings | None = None,
lifecycle: TokenLifecycle | None = None,
timeout: int = 15,
):
self.settings = settings or SchwabSettings.from_env()
self.lifecycle = lifecycle or TokenLifecycle(self.settings)
self.timeout = timeout
def read_orders(self, days_back: int = 2) -> list[dict]:
token = self.lifecycle.get_access_token()
if not token:
return []
now = datetime.now(timezone.utc)
params = urllib.parse.urlencode(
{
"fromEnteredTime": (now - timedelta(days=days_back)).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
),
"toEnteredTime": now.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
}
)
url = f"{self.settings.api_base_url}/trader/v1/orders?{params}"
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {token}"}
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = json.loads(resp.read().decode() or "[]")
except (urllib.error.URLError, OSError, json.JSONDecodeError):
logger.exception("schwab orders read failed")
return []
return [
{
"broker_order_id": str(o.get("orderId") or ""),
"symbol": (o.get("orderLegCollection") or [{}])[0]
.get("instrument", {})
.get("symbol"),
"side": (o.get("orderLegCollection") or [{}])[0].get("instruction"),
"quantity": o.get("quantity"),
"filled_quantity": o.get("filledQuantity"),
"status": o.get("status"),
"type": o.get("orderType"),
"price": o.get("price"),
"submitted_at": o.get("enteredTime"),
}
for o in raw
]
+45
View File
@@ -0,0 +1,45 @@
"""Sync Schwab orders into the event store."""
from __future__ import annotations
import logging
from datetime import datetime
from schwab.orders.reader import OrdersReader
from storage.event_schema import AccountOrdersUpdatedEvent, EventMode
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
BROKER_NAME = "schwab"
class OrderSync:
def __init__(
self,
store: EventStore,
reader: OrdersReader | None = None,
session_id: str | None = None,
mode: EventMode = EventMode.LIVE,
account_id: str = "schwab",
):
self.store = store
self.reader = reader or OrdersReader()
self.session_id = session_id
self.mode = mode
self.account_id = account_id
def sync_orders(self) -> list[dict]:
orders = self.reader.read_orders()
self.store.emit(
AccountOrdersUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Schwab orders: {len(orders)}",
broker_name=BROKER_NAME,
account_id=self.account_id,
orders=orders,
)
)
return orders
+13
View File
@@ -0,0 +1,13 @@
"""Schwab account & positions (read-only)."""
from schwab.positions.models import AccountSummary, AccountPositions, Position
from schwab.positions.reader import PositionsReader
from schwab.positions.sync import AccountSync
__all__ = [
"AccountSummary",
"AccountPositions",
"Position",
"PositionsReader",
"AccountSync",
]
+33
View File
@@ -0,0 +1,33 @@
"""Schwab account/position models."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class AccountSummary:
account_id: str
account_desc: str = "Schwab"
total_equity: float = 0.0
cash_balance: float = 0.0
buying_power: float = 0.0
net_liquidating_value: float = 0.0
is_fallback: bool = False
@dataclass
class Position:
symbol: str
quantity: float = 0.0
avg_entry_price: float = 0.0
market_value: float = 0.0
unrealized_pnl: float = 0.0
asset_type: str = "EQUITY"
@dataclass
class AccountPositions:
account_id: str
positions: list[Position] = field(default_factory=list)
is_fallback: bool = False
+111
View File
@@ -0,0 +1,111 @@
"""Read Schwab account summary and positions.
When no valid token exists (the common case before OAuth setup), the reader
returns clearly-labelled fallback data rather than crashing, so the rest of
the system — and its tests — keep working.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from schwab.auth.lifecycle import TokenLifecycle
from schwab.positions.models import AccountPositions, AccountSummary, Position
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
class PositionsReader:
def __init__(
self,
settings: SchwabSettings | None = None,
lifecycle: TokenLifecycle | None = None,
timeout: int = 15,
):
self.settings = settings or SchwabSettings.from_env()
self.lifecycle = lifecycle or TokenLifecycle(self.settings)
self.timeout = timeout
def _get(self, path: str, token: str, params: dict | None = None):
url = f"{self.settings.api_base_url}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode() or "{}")
# -- fallbacks -------------------------------------------------------
@staticmethod
def _fallback_account_summary() -> AccountSummary:
return AccountSummary(
account_id="SCHWAB-UNAUTH",
account_desc="Schwab (not authenticated — fallback data)",
is_fallback=True,
)
@staticmethod
def _fallback_positions() -> AccountPositions:
return AccountPositions(account_id="SCHWAB-UNAUTH", is_fallback=True)
# -- public ----------------------------------------------------------
def read_account_summary(self) -> AccountSummary:
token = self.lifecycle.get_access_token()
if not token:
return self._fallback_account_summary()
try:
accounts = self._get("/trader/v1/accounts", token)
except (urllib.error.URLError, OSError, json.JSONDecodeError):
logger.exception("schwab account read failed")
return self._fallback_account_summary()
if not accounts:
return self._fallback_account_summary()
account = accounts[0].get("securitiesAccount", accounts[0])
balances = account.get("currentBalances", {})
return AccountSummary(
account_id=str(account.get("accountNumber") or "schwab"),
account_desc=account.get("type", "Schwab"),
total_equity=float(balances.get("equity") or 0),
cash_balance=float(balances.get("cashBalance") or 0),
buying_power=float(balances.get("buyingPower") or 0),
net_liquidating_value=float(balances.get("liquidationValue") or 0),
)
def read_positions(self) -> AccountPositions:
token = self.lifecycle.get_access_token()
if not token:
return self._fallback_positions()
try:
accounts = self._get(
"/trader/v1/accounts", token, params={"fields": "positions"}
)
except (urllib.error.URLError, OSError, json.JSONDecodeError):
logger.exception("schwab positions read failed")
return self._fallback_positions()
if not accounts:
return self._fallback_positions()
account = accounts[0].get("securitiesAccount", accounts[0])
positions = [
Position(
symbol=p.get("instrument", {}).get("symbol", ""),
quantity=float(p.get("longQuantity") or 0)
- float(p.get("shortQuantity") or 0),
avg_entry_price=float(p.get("averagePrice") or 0),
market_value=float(p.get("marketValue") or 0),
unrealized_pnl=float(p.get("longOpenProfitLoss") or 0),
asset_type=p.get("instrument", {}).get("assetType", "EQUITY"),
)
for p in account.get("positions") or []
]
return AccountPositions(
account_id=str(account.get("accountNumber") or "schwab"),
positions=positions,
)
+70
View File
@@ -0,0 +1,70 @@
"""Sync Schwab account state into the event store."""
from __future__ import annotations
import logging
from dataclasses import asdict
from datetime import datetime
from schwab.positions.reader import PositionsReader
from storage.event_schema import (
AccountPositionsUpdatedEvent,
AccountSummaryUpdatedEvent,
EventMode,
)
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
BROKER_NAME = "schwab"
class AccountSync:
def __init__(
self,
store: EventStore,
reader: PositionsReader | None = None,
session_id: str | None = None,
mode: EventMode = EventMode.LIVE,
):
self.store = store
self.reader = reader or PositionsReader()
self.session_id = session_id
self.mode = mode
def sync_summary(self) -> dict:
summary = self.reader.read_account_summary()
self.store.emit(
AccountSummaryUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Schwab account {summary.account_id} equity {summary.total_equity}",
broker_name=BROKER_NAME,
account_id=summary.account_id,
account_desc=summary.account_desc,
total_equity=summary.total_equity,
cash_balance=summary.cash_balance,
buying_power=summary.buying_power,
net_liquidating_value=summary.net_liquidating_value,
)
)
return asdict(summary)
def sync_positions(self) -> dict:
snapshot = self.reader.read_positions()
self.store.emit(
AccountPositionsUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Schwab positions: {len(snapshot.positions)}",
broker_name=BROKER_NAME,
account_id=snapshot.account_id,
positions=[asdict(p) for p in snapshot.positions],
)
)
return asdict(snapshot)
def sync_all(self) -> dict:
return {"summary": self.sync_summary(), "positions": self.sync_positions()}
+41
View File
@@ -0,0 +1,41 @@
"""Schwab API settings (Milestone 3)."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class SchwabSettings:
market_data_app_key: str = ""
market_data_app_secret: str = ""
broker_app_key: str = ""
broker_app_secret: str = ""
redirect_uri: str = "https://127.0.0.1:8182/callback"
token_path: str = "data/schwab_tokens.json"
api_base_url: str = "https://api.schwabapi.com"
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "SchwabSettings":
values = dict(os.environ)
if env is not None:
values.update(env)
return cls(
market_data_app_key=values.get("SCHWAB_MARKET_DATA_APP_KEY", ""),
market_data_app_secret=values.get("SCHWAB_MARKET_DATA_APP_SECRET", ""),
broker_app_key=values.get("SCHWAB_BROKER_APP_KEY", ""),
broker_app_secret=values.get("SCHWAB_BROKER_APP_SECRET", ""),
redirect_uri=values.get(
"SCHWAB_REDIRECT_URI", "https://127.0.0.1:8182/callback"
),
token_path=values.get("SCHWAB_TOKEN_PATH", "data/schwab_tokens.json"),
)
@property
def has_market_data_credentials(self) -> bool:
return bool(self.market_data_app_key and self.market_data_app_secret)
@property
def has_broker_credentials(self) -> bool:
return bool(self.broker_app_key and self.broker_app_secret)
+5
View File
@@ -0,0 +1,5 @@
"""Schwab streaming (scaffold)."""
from schwab.streaming.client import StreamingClient, StreamerInfo
__all__ = ["StreamingClient", "StreamerInfo"]
+87
View File
@@ -0,0 +1,87 @@
"""Schwab streaming client scaffold.
(Repaired: original had dataclass default-ordering errors and unbalanced
parentheses.) Real-time streaming requires the streamer-info handshake from
user preferences; this scaffold builds the login payload and exposes a
callback interface, while the live system polls REST minute bars instead.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Callable
logger = logging.getLogger(__name__)
@dataclass
class StreamerInfo:
streamer_socket_url: str = ""
schwab_client_customer_id: str = ""
schwab_client_correl_id: str = ""
schwab_client_channel: str = "N9"
schwab_client_function_id: str = "APIAPP"
@classmethod
def from_user_preferences(cls, payload: dict) -> "StreamerInfo":
info = (payload.get("streamerInfo") or [{}])[0]
return cls(
streamer_socket_url=info.get("streamerSocketUrl", ""),
schwab_client_customer_id=info.get("schwabClientCustomerId", ""),
schwab_client_correl_id=info.get("schwabClientCorrelId", ""),
schwab_client_channel=info.get("schwabClientChannel", "N9"),
schwab_client_function_id=info.get("schwabClientFunctionId", "APIAPP"),
)
@dataclass
class StreamingClient:
streamer_info: StreamerInfo
access_token: str
request_id: int = 0
on_message: Callable[[dict], None] | None = None
subscriptions: list[dict] = field(default_factory=list)
def _next_request_id(self) -> int:
self.request_id += 1
return self.request_id
def build_login_request(self) -> dict:
return {
"service": "ADMIN",
"command": "LOGIN",
"requestid": self._next_request_id(),
"SchwabClientCustomerId": self.streamer_info.schwab_client_customer_id,
"SchwabClientCorrelId": self.streamer_info.schwab_client_correl_id,
"parameters": {
"Authorization": self.access_token,
"SchwabClientChannel": self.streamer_info.schwab_client_channel,
"SchwabClientFunctionId": self.streamer_info.schwab_client_function_id,
},
}
def build_quote_subscription(self, symbols: list[str]) -> dict:
request = {
"service": "LEVELONE_EQUITIES",
"command": "SUBS",
"requestid": self._next_request_id(),
"SchwabClientCustomerId": self.streamer_info.schwab_client_customer_id,
"SchwabClientCorrelId": self.streamer_info.schwab_client_correl_id,
"parameters": {
"keys": ",".join(symbols),
"fields": "0,1,2,3,4,5,8",
},
}
self.subscriptions.append(request)
return request
def handle_raw_message(self, raw: str) -> None:
try:
payload = json.loads(raw)
except json.JSONDecodeError:
logger.warning("unparseable streaming message: %.120s", raw)
return
if self.on_message:
self.on_message(payload)
+47
View File
@@ -0,0 +1,47 @@
"""Event model for Milestone 2.
Append-only event store with canonical event types.
"""
from .event_schema import (
EventType,
BaseEvent,
SymbolDiscoveredEvent,
SymbolStateChangedEvent,
CriteriaEvaluatedEvent,
SignalReadyEvent,
SignalBlockedEvent,
OrderSubmittedEvent,
OrderApprovalRequestedEvent,
OrderApprovedEvent,
OrderRejectedEvent,
OrderFilledEvent,
PositionOpenedEvent,
PositionClosedEvent,
RiskRuleTriggeredEvent,
BrokerHealthChangedEvent,
SourceHealthChangedEvent,
)
from .event_store import EventStore
__all__ = [
"EventType",
"BaseEvent",
"SymbolDiscoveredEvent",
"SymbolStateChangedEvent",
"CriteriaEvaluatedEvent",
"SignalReadyEvent",
"SignalBlockedEvent",
"OrderSubmittedEvent",
"OrderApprovalRequestedEvent",
"OrderApprovedEvent",
"OrderRejectedEvent",
"OrderFilledEvent",
"PositionOpenedEvent",
"PositionClosedEvent",
"RiskRuleTriggeredEvent",
"BrokerHealthChangedEvent",
"SourceHealthChangedEvent",
"EventStore",
]
+34
View File
@@ -0,0 +1,34 @@
"""DuckDB connection management."""
import importlib
from pathlib import Path
from typing import Any
from .schema import create_schema
def get_connection(
db_path: str | Path = "./data/momentum.duckdb",
) -> Any:
"""Get a DuckDB connection, creating schema if needed."""
duckdb = importlib.import_module("duckdb")
if str(db_path) == ":memory:":
con = duckdb.connect(":memory:")
create_schema(con)
return con
db_path = Path(db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
con = duckdb.connect(str(db_path))
create_schema(con)
return con
def get_memory_connection() -> Any:
"""Get an in-memory DuckDB connection for testing."""
duckdb = importlib.import_module("duckdb")
con = duckdb.connect(":memory:")
create_schema(con)
return con
+344
View File
@@ -0,0 +1,344 @@
"""Event schema definitions for Milestone 2.
All canonical event types that can be emitted by replay, paper, and live modes.
"""
from enum import Enum
from datetime import datetime
from pydantic import BaseModel, Field
class EventMode(str, Enum):
"""Runtime modes for events."""
REPLAY = "replay"
PAPER = "paper"
LIVE = "live"
class EventType(str, Enum):
"""Canonical event types."""
SYMBOL_DISCOVERED = "symbol_discovered"
SYMBOL_STATE_CHANGED = "symbol_state_changed"
CRITERIA_EVALUATED = "criteria_evaluated"
SIGNAL_READY = "signal_ready"
SIGNAL_BLOCKED = "signal_blocked"
ORDER_SUBMITTED = "order_submitted"
ORDER_APPROVAL_REQUESTED = "order_approval_requested"
ORDER_APPROVED = "order_approved"
ORDER_REJECTED = "order_rejected"
ORDER_FILLED = "order_filled"
ORDER_CANCELLED = "order_cancelled"
POSITION_OPENED = "position_opened"
POSITION_CLOSED = "position_closed"
ACCOUNT_SUMMARY_UPDATED = "account_summary_updated"
ACCOUNT_POSITIONS_UPDATED = "account_positions_updated"
ACCOUNT_ORDERS_UPDATED = "account_orders_updated"
RISK_RULE_TRIGGERED = "risk_rule_triggered"
BROKER_HEALTH_CHANGED = "broker_health_changed"
SOURCE_HEALTH_CHANGED = "source_health_changed"
SESSION_SUMMARY = "session_summary"
MODULE_TICK = "module_tick"
DATA_VALIDATION = "data_validation"
SYMBOL_EVALUATION_DETAIL = "symbol_evaluation_detail"
class BaseEvent(BaseModel):
"""Base event with common fields."""
timestamp: datetime = Field(description="Event timestamp")
mode: EventMode = Field(description="Runtime mode (replay/paper/live)")
event_type: EventType = Field(description="Event type identifier")
correlation_id: str | None = Field(
default=None, description="Correlation ID for causal chain"
)
message: str = Field(description="Human-readable message")
payload: dict = Field(default_factory=dict, description="Structured event data")
class SymbolDiscoveredEvent(BaseEvent):
"""Event when a symbol is discovered."""
event_type: EventType = Field(
default=EventType.SYMBOL_DISCOVERED, description="Event type identifier"
)
symbol: str = Field(description="Ticker symbol")
symbol_data: dict = Field(default_factory=dict, description="Symbol metadata")
class SymbolStateChangedEvent(BaseEvent):
"""Event when symbol state changes."""
event_type: EventType = Field(
default=EventType.SYMBOL_STATE_CHANGED, description="Event type identifier"
)
symbol: str = Field(description="Ticker symbol")
previous_state: str | None = Field(default=None, description="Previous state")
new_state: str = Field(description="New state")
state_reason: str | None = Field(
default=None, description="Reason for state change"
)
class CriteriaEvaluatedEvent(BaseEvent):
"""Event when criteria are evaluated."""
event_type: EventType = Field(
default=EventType.CRITERIA_EVALUATED, description="Event type identifier"
)
symbol: str = Field(description="Ticker symbol")
criteria_results: dict = Field(description="Criteria evaluation results")
total_criteria: int = Field(description="Total number of criteria")
passed_criteria: int = Field(description="Number of criteria passed")
success_score_pct: float = Field(description="Success score percentage")
class SignalReadyEvent(BaseEvent):
"""Event when signal is ready."""
event_type: EventType = Field(
default=EventType.SIGNAL_READY, description="Event type identifier"
)
symbol: str = Field(description="Ticker symbol")
signal_type: str = Field(description="Type of signal (e.g., first_pullback)")
confidence: float = Field(description="Signal confidence score")
signal_data: dict = Field(default_factory=dict, description="Execution metadata")
class SignalBlockedEvent(BaseEvent):
"""Event when signal is blocked."""
event_type: EventType = Field(
default=EventType.SIGNAL_BLOCKED, description="Event type identifier"
)
symbol: str = Field(description="Ticker symbol")
blocking_reason: str = Field(description="Reason signal is blocked")
unmet_criteria: list[str] = Field(
default_factory=list, description="List of unmet criteria"
)
class OrderSubmittedEvent(BaseEvent):
"""Event when order is submitted."""
event_type: EventType = Field(
default=EventType.ORDER_SUBMITTED, description="Event type identifier"
)
order_id: str = Field(description="Order ID")
symbol: str = Field(description="Ticker symbol")
side: str = Field(description="Buy or sell")
quantity: int = Field(description="Order quantity")
price: float = Field(description="Order price")
class OrderApprovalRequestedEvent(BaseEvent):
"""Event when order approval is requested."""
event_type: EventType = Field(
default=EventType.ORDER_APPROVAL_REQUESTED, description="Event type identifier"
)
order_id: str = Field(description="Order ID")
symbol: str = Field(description="Ticker symbol")
requested_by: str = Field(description="Who requested approval")
approval_mode: str = Field(description="Approval mode (auto/manual)")
execution_mode: str = Field(description="Target execution mode")
execution_request: dict = Field(
default_factory=dict, description="Pending execution payload"
)
class OrderApprovedEvent(BaseEvent):
"""Event when order is approved."""
event_type: EventType = Field(
default=EventType.ORDER_APPROVED, description="Event type identifier"
)
order_id: str = Field(description="Order ID")
symbol: str = Field(description="Ticker symbol")
approved_by: str = Field(description="Who approved the order")
approval_notes: str | None = Field(default=None, description="Approval notes")
class OrderRejectedEvent(BaseEvent):
"""Event when order is rejected."""
event_type: EventType = Field(
default=EventType.ORDER_REJECTED, description="Event type identifier"
)
order_id: str = Field(description="Order ID")
symbol: str = Field(description="Ticker symbol")
rejected_by: str = Field(description="Who rejected the order")
rejection_reason: str = Field(description="Reason for rejection")
class OrderFilledEvent(BaseEvent):
"""Event when order is filled."""
event_type: EventType = Field(
default=EventType.ORDER_FILLED, description="Event type identifier"
)
order_id: str = Field(description="Order ID")
symbol: str = Field(description="Ticker symbol")
fill_price: float = Field(description="Fill price")
fill_quantity: int = Field(description="Filled quantity")
slippage: float | None = Field(default=None, description="Slippage amount")
class OrderCancelledEvent(BaseEvent):
event_type: EventType = Field(
default=EventType.ORDER_CANCELLED, description="Event type identifier"
)
order_id: str = Field(description="Order ID")
symbol: str = Field(description="Ticker symbol")
cancel_reason: str | None = Field(default=None, description="Cancel reason")
class PositionOpenedEvent(BaseEvent):
"""Event when position is opened."""
event_type: EventType = Field(
default=EventType.POSITION_OPENED, description="Event type identifier"
)
position_id: str = Field(description="Position ID")
symbol: str = Field(description="Ticker symbol")
entry_price: float = Field(description="Entry price")
quantity: int = Field(description="Position quantity")
stop_loss_price: float = Field(description="Stop loss price")
class PositionClosedEvent(BaseEvent):
"""Event when position is closed."""
event_type: EventType = Field(
default=EventType.POSITION_CLOSED, description="Event type identifier"
)
position_id: str = Field(description="Position ID")
symbol: str = Field(description="Ticker symbol")
exit_price: float = Field(description="Exit price")
exit_reason: str = Field(description="Reason for position close")
realized_pnl: float = Field(description="Realized profit/loss")
class AccountSummaryUpdatedEvent(BaseEvent):
event_type: EventType = Field(
default=EventType.ACCOUNT_SUMMARY_UPDATED,
description="Event type identifier",
)
broker_name: str = Field(description="Broker identifier")
account_id: str = Field(description="Broker account identifier")
account_desc: str = Field(description="Display name for account")
total_equity: float = Field(description="Total account equity")
cash_balance: float = Field(description="Available cash balance")
buying_power: float = Field(description="Buying power")
net_liquidating_value: float = Field(description="Net liquidation value")
class AccountPositionsUpdatedEvent(BaseEvent):
event_type: EventType = Field(
default=EventType.ACCOUNT_POSITIONS_UPDATED,
description="Event type identifier",
)
broker_name: str = Field(description="Broker identifier")
account_id: str = Field(description="Broker account identifier")
positions: list[dict] = Field(description="Serialized account positions")
class AccountOrdersUpdatedEvent(BaseEvent):
event_type: EventType = Field(
default=EventType.ACCOUNT_ORDERS_UPDATED,
description="Event type identifier",
)
broker_name: str = Field(description="Broker identifier")
account_id: str = Field(description="Broker account identifier")
orders: list[dict] = Field(description="Serialized account orders")
class RiskRuleTriggeredEvent(BaseEvent):
"""Event when risk rule is triggered."""
event_type: EventType = EventType.RISK_RULE_TRIGGERED
rule_type: str = Field(description="Type of risk rule")
rule_value: float = Field(description="Value that triggered the rule")
current_state: dict = Field(description="Current system state when triggered")
action_taken: str = Field(description="Action taken in response")
class BrokerHealthChangedEvent(BaseEvent):
"""Event when broker health changes."""
event_type: EventType = Field(
default=EventType.BROKER_HEALTH_CHANGED, description="Event type identifier"
)
broker_name: str = Field(description="Broker identifier")
previous_health: str = Field(description="Previous health status")
new_health: str = Field(description="New health status")
health_reason: str | None = Field(
default=None, description="Reason for health change"
)
class SourceHealthChangedEvent(BaseEvent):
"""Event when source health changes."""
event_type: EventType = EventType.SOURCE_HEALTH_CHANGED
source_name: str = Field(description="Source identifier")
source_type: str = Field(description="Source type (broker/market data/news)")
previous_health: str = Field(description="Previous health status")
new_health: str = Field(description="New health status")
health_reason: str | None = Field(
default=None, description="Reason for health change"
)
class SessionSummaryEvent(BaseEvent):
event_type: EventType = Field(default=EventType.SESSION_SUMMARY)
session_id: str = Field(description="Unique session identifier")
status: str = Field(description="Session status")
summary_data: dict = Field(default_factory=dict, description="Session summary data")
class ModuleTickEvent(BaseEvent):
"""Event for module execution telemetry."""
event_type: EventType = Field(
default=EventType.MODULE_TICK, description="Event type identifier"
)
module: str = Field(description="Module name (ingestion, research, evaluation, etc.)")
stage: str = Field(description="Stage (started, processing, completed, failed)")
duration_ms: float = Field(description="Execution duration in milliseconds")
input_count: int = Field(description="Number of inputs processed")
output_count: int = Field(description="Number of outputs produced")
metrics: dict = Field(default_factory=dict, description="Custom module metrics")
errors: list[dict] = Field(default_factory=list, description="Any errors that occurred")
class DataValidationEvent(BaseEvent):
"""Event for data quality checkpoints."""
event_type: EventType = Field(
default=EventType.DATA_VALIDATION, description="Event type identifier"
)
module: str = Field(description="Module performing validation")
validation_type: str = Field(
description="Type of validation (schema, checksum, business_rules)"
)
valid: bool = Field(description="Whether validation passed")
details: dict = Field(default_factory=dict, description="Validation details")
class SymbolEvaluationDetailEvent(BaseEvent):
"""Event for detailed symbol evaluation metrics at each stage."""
event_type: EventType = Field(
default=EventType.SYMBOL_EVALUATION_DETAIL, description="Event type identifier"
)
symbol: str = Field(description="Ticker symbol")
stage: str = Field(
description="Evaluation stage (bars_loaded, gap_checked, rvol_calculated, etc.)"
)
metrics: dict = Field(description="Metrics at this stage")
passed: bool = Field(description="Whether this stage passed")
blocking_reason: str | None = Field(
default=None, description="If failed, why it failed"
)
+223
View File
@@ -0,0 +1,223 @@
"""Event store implementation for Milestone 2.
Append-only event storage with canonical event types.
"""
from .event_schema import (
BaseEvent,
EventType,
EventMode,
)
from .db import get_connection
from uuid import uuid4
import json
import logging
logger = logging.getLogger(__name__)
class EventStore:
"""Append-only event store for canonical events."""
def __init__(self, db_path: str = "./data/momentum_events.duckdb"):
"""Initialize event store with DuckDB connection."""
self.con = get_connection(db_path)
self._ensure_schema()
def _ensure_schema(self):
"""Ensure event tables exist in database."""
cursor = self.con.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS events (
id VARCHAR PRIMARY KEY,
timestamp TIMESTAMP,
mode VARCHAR,
event_type VARCHAR,
correlation_id VARCHAR,
message VARCHAR,
payload_json TEXT,
created_at TIMESTAMP DEFAULT current_timestamp
);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_events_timestamp
ON events(timestamp);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_events_session_id
ON events(correlation_id);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_events_symbol
ON events(payload_json);
""")
self.con.commit()
def emit(self, event: BaseEvent) -> str:
"""Emit an event to the event store.
Args:
event: Event to emit
Returns:
Event ID
"""
event_id = str(uuid4())
payload_json = json.dumps(event.model_dump(mode="json", exclude_unset=True))
cursor = self.con.cursor()
cursor.execute(
"""
INSERT INTO events
(id, timestamp, mode, event_type, correlation_id, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, current_timestamp)
""",
[
event_id,
event.timestamp,
event.mode.value,
event.event_type.value
if isinstance(event.event_type, EventType)
else str(event.event_type),
event.correlation_id,
event.message,
payload_json,
],
)
self.con.commit()
logger.debug(
f"Emitted event {event_id}: {event.event_type.value} - {event.message}"
)
return event_id
def query_events(
self,
event_type: str | None = None,
symbol: str | None = None,
correlation_id: str | None = None,
session_id: str | None = None,
since: str | None = None,
until: str | None = None,
limit: int | None = 1000,
) -> list[dict]:
"""Query events from the store.
Args:
event_type: Filter by event type
symbol: Filter by symbol
correlation_id: Filter by correlation ID
session_id: Filter by session ID
since: Start timestamp (inclusive)
until: End timestamp (exclusive)
limit: Maximum number of results
Returns:
List of event dictionaries
"""
conditions = []
params = []
if event_type is not None:
conditions.append("event_type = ?")
params.append(event_type)
if symbol is not None:
conditions.append("payload_json LIKE ?")
params.append(f"%\"symbol\": \"{symbol}\"%")
if correlation_id is not None:
conditions.append("correlation_id = ?")
params.append(correlation_id)
if session_id is not None:
conditions.append("correlation_id = ?")
params.append(session_id)
if since is not None:
conditions.append("timestamp >= ?")
params.append(since)
if until is not None:
conditions.append("timestamp < ?")
params.append(until)
where_clause = " AND ".join(conditions) if conditions else "1=1"
limit_clause = f"LIMIT {limit}" if limit is not None else ""
query = f"""
SELECT id, timestamp, mode, event_type, correlation_id, message, payload_json, created_at
FROM events
WHERE {where_clause}
ORDER BY timestamp ASC, created_at ASC
{limit_clause}
"""
cursor = self.con.cursor()
cursor.execute(query, params)
rows = cursor.fetchall()
results = []
for row in rows:
results.append(
{
"id": row[0],
"timestamp": str(row[1]),
"mode": row[2],
"event_type": row[3],
"correlation_id": row[4],
"message": row[5],
"payload_json": row[6],
"created_at": str(row[7]),
}
)
return results
def count_events(self) -> int:
"""Total number of events. Cheap change-detection signal for SSE."""
cursor = self.con.cursor()
cursor.execute("SELECT COUNT(*) FROM events")
row = cursor.fetchone()
return int(row[0]) if row else 0
def get_symbol_timeline(self, symbol: str) -> list[dict]:
"""Get full timeline for a symbol.
Args:
symbol: Ticker symbol
Returns:
List of all events for the symbol in chronological order
"""
return self.query_events(symbol=symbol, limit=None)
def rebuild_state(self, session_id: str) -> dict:
"""Rebuild state from event store for a session.
Args:
session_id: Session ID to rebuild
Returns:
Dictionary with session summary and latest state
"""
events = self.query_events(session_id=session_id, limit=None)
summary = {
"session_id": session_id,
"total_events": len(events),
"event_types": {},
}
for event in events:
event_type = event["event_type"]
summary["event_types"][event_type] = (
summary["event_types"].get(event_type, 0) + 1
)
return summary
def close(self):
"""Close event store connection."""
if self.con:
self.con.close()
+451
View File
@@ -0,0 +1,451 @@
-- PostgreSQL schema for the momentum system (single datastore).
--
-- Port of storage/schema.py (DuckDB) + the event store `events` table that
-- previously lived in storage/event_store.py. Dialect deltas applied:
-- DOUBLE -> DOUBLE PRECISION
-- payload index -> on event_type/correlation_id (a btree on unbounded
-- TEXT payload is invalid in Postgres)
-- everything else -> already Postgres-compatible (DuckDB dialect is
-- Postgres-derived: IF NOT EXISTS, BOOLEAN, VARCHAR,
-- composite PKs, current_timestamp defaults all work).
--
-- Idempotent: safe to run repeatedly (CREATE ... IF NOT EXISTS throughout).
-- =====================================================================
-- Operational event store (append-only source of truth)
-- =====================================================================
CREATE TABLE IF NOT EXISTS events (
id VARCHAR PRIMARY KEY,
timestamp TIMESTAMP,
mode VARCHAR,
event_type VARCHAR,
correlation_id VARCHAR,
message VARCHAR,
payload_json TEXT,
created_at TIMESTAMP DEFAULT current_timestamp
);
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events (timestamp);
CREATE INDEX IF NOT EXISTS idx_events_correlation ON events (correlation_id);
CREATE INDEX IF NOT EXISTS idx_events_type ON events (event_type);
-- =====================================================================
-- Universe metadata
-- =====================================================================
CREATE TABLE IF NOT EXISTS symbols (
symbol VARCHAR PRIMARY KEY,
security_name VARCHAR,
exchange VARCHAR,
asset_type VARCHAR DEFAULT 'common_stock',
sector VARCHAR,
industry VARCHAR,
shares_outstanding BIGINT,
float_shares BIGINT,
market_cap DOUBLE PRECISION,
is_etf BOOLEAN DEFAULT FALSE,
is_otc BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT current_timestamp,
updated_at TIMESTAMP DEFAULT current_timestamp
);
CREATE TABLE IF NOT EXISTS trading_days (
trade_date DATE PRIMARY KEY,
session_open TIMESTAMP,
session_close TIMESTAMP,
premarket_open TIMESTAMP,
afterhours_close TIMESTAMP,
is_half_day BOOLEAN DEFAULT FALSE,
is_holiday_adjacent BOOLEAN DEFAULT FALSE,
tape_regime VARCHAR,
market_regime VARCHAR,
volatility_regime VARCHAR,
gap_activity_level INTEGER,
momentum_density INTEGER
);
-- =====================================================================
-- Bars
-- =====================================================================
CREATE TABLE IF NOT EXISTS daily_bars (
symbol VARCHAR,
trade_date DATE,
open DOUBLE PRECISION,
high DOUBLE PRECISION,
low DOUBLE PRECISION,
close DOUBLE PRECISION,
volume BIGINT,
vwap DOUBLE PRECISION,
previous_close DOUBLE PRECISION,
true_range DOUBLE PRECISION,
atr_14 DOUBLE PRECISION,
rolling_avg_volume_20d DOUBLE PRECISION,
rolling_avg_volume_50d DOUBLE PRECISION,
prior_day_gain_pct DOUBLE PRECISION,
multi_day_runner_flag BOOLEAN DEFAULT FALSE,
PRIMARY KEY (symbol, trade_date)
);
CREATE TABLE IF NOT EXISTS minute_bars (
symbol VARCHAR,
timestamp TIMESTAMP,
session_date DATE,
is_premarket BOOLEAN,
is_regular_hours BOOLEAN,
is_afterhours BOOLEAN,
open DOUBLE PRECISION,
high DOUBLE PRECISION,
low DOUBLE PRECISION,
close DOUBLE PRECISION,
volume BIGINT,
vwap DOUBLE PRECISION,
spread_pct DOUBLE PRECISION,
halt_status BOOLEAN DEFAULT FALSE,
source_provider VARCHAR DEFAULT 'synthetic',
quality_score DOUBLE PRECISION,
PRIMARY KEY (symbol, timestamp)
);
-- Fast per-symbol/session bar lookups (the `inspect bars` path).
CREATE INDEX IF NOT EXISTS idx_minute_bars_symbol_session
ON minute_bars (symbol, session_date);
-- =====================================================================
-- News / catalysts / halts / context
-- =====================================================================
CREATE TABLE IF NOT EXISTS news_events (
id VARCHAR,
symbol VARCHAR,
headline VARCHAR,
source VARCHAR,
published_at TIMESTAMP,
catalyst_freshness_minutes DOUBLE PRECISION,
category VARCHAR,
sentiment DOUBLE PRECISION,
is_earnings BOOLEAN DEFAULT FALSE,
is_offering BOOLEAN DEFAULT FALSE,
is_halt_related BOOLEAN DEFAULT FALSE,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS halt_events (
id VARCHAR,
symbol VARCHAR,
halt_start TIMESTAMP,
halt_end TIMESTAMP,
halt_type VARCHAR,
price_at_halt DOUBLE PRECISION,
resumed_price DOUBLE PRECISION,
gap_on_resume_pct DOUBLE PRECISION,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS market_context (
timestamp TIMESTAMP,
trade_date DATE,
spy_open DOUBLE PRECISION,
spy_high DOUBLE PRECISION,
spy_low DOUBLE PRECISION,
spy_close DOUBLE PRECISION,
spy_volume BIGINT,
qqq_open DOUBLE PRECISION,
qqq_close DOUBLE PRECISION,
spy_intraday_return_pct DOUBLE PRECISION,
vix_open DOUBLE PRECISION,
vix_level DOUBLE PRECISION,
advance_decline_ratio DOUBLE PRECISION,
up_volume_down_volume_ratio DOUBLE PRECISION,
tape_regime_flag VARCHAR,
PRIMARY KEY (timestamp)
);
CREATE TABLE IF NOT EXISTS dilution_events (
id VARCHAR PRIMARY KEY,
symbol VARCHAR,
event_type VARCHAR,
announced_at TIMESTAMP,
effective_date DATE,
share_count BIGINT,
price_per_share DOUBLE PRECISION,
gross_proceeds DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS short_interest (
symbol VARCHAR,
report_date DATE,
short_interest_shares BIGINT,
short_interest_pct_float DOUBLE PRECISION,
days_to_cover DOUBLE PRECISION,
PRIMARY KEY (symbol, report_date)
);
-- =====================================================================
-- Features / setups / outcomes / backtests
-- =====================================================================
CREATE TABLE IF NOT EXISTS engineered_features (
id VARCHAR PRIMARY KEY,
timestamp TIMESTAMP,
symbol VARCHAR,
session_date DATE,
feature_version VARCHAR,
gap_pct DOUBLE PRECISION,
premarket_gap_pct DOUBLE PRECISION,
relative_volume DOUBLE PRECISION,
time_of_day_adjusted_relative_volume DOUBLE PRECISION,
float_rotation_pct DOUBLE PRECISION,
vwap DOUBLE PRECISION,
distance_from_vwap DOUBLE PRECISION,
ema9 DOUBLE PRECISION,
ema20 DOUBLE PRECISION,
pullback_volume_ratio DOUBLE PRECISION,
pullback_candle_type VARCHAR,
catalyst_freshness_minutes DOUBLE PRECISION,
catalyst_type VARCHAR,
tape_regime VARCHAR,
spy_intraday_return DOUBLE PRECISION,
vix_level DOUBLE PRECISION,
metadata_json VARCHAR
);
CREATE TABLE IF NOT EXISTS setup_events (
setup_id VARCHAR PRIMARY KEY,
symbol VARCHAR,
setup_time TIMESTAMP,
session_date DATE,
setup_name VARCHAR DEFAULT 'first_pullback',
setup_version VARCHAR DEFAULT 'v1',
entry_reference_price DOUBLE PRECISION,
invalidation_price DOUBLE PRECISION,
target_r_multiple DOUBLE PRECISION DEFAULT 2.0,
impulse_start_price DOUBLE PRECISION,
impulse_end_price DOUBLE PRECISION,
impulse_pct DOUBLE PRECISION,
pullback_low DOUBLE PRECISION,
pullback_depth_pct DOUBLE PRECISION,
pullback_bars INTEGER,
pullback_volume_ratio DOUBLE PRECISION,
above_vwap_flag BOOLEAN,
vwap_at_trigger DOUBLE PRECISION,
gap_pct DOUBLE PRECISION,
relative_volume DOUBLE PRECISION,
float_rotation_at_trigger DOUBLE PRECISION,
catalyst_freshness_at_trigger DOUBLE PRECISION,
session_minute_number INTEGER,
tape_regime_at_trigger VARCHAR,
spy_intraday_return_pct_at_trigger DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS outcome_labels (
setup_id VARCHAR PRIMARY KEY,
label_version VARCHAR DEFAULT 'v1',
max_upside_next_5m DOUBLE PRECISION,
max_upside_next_15m DOUBLE PRECISION,
max_upside_next_60m DOUBLE PRECISION,
max_drawdown_next_5m DOUBLE PRECISION,
max_drawdown_next_15m DOUBLE PRECISION,
max_drawdown_next_60m DOUBLE PRECISION,
reached_1r_before_minus_1r BOOLEAN,
reached_2r_before_minus_1r BOOLEAN,
held_vwap_5m BOOLEAN,
held_vwap_15m BOOLEAN,
trend_day_flag BOOLEAN,
failed_breakout_flag BOOLEAN,
time_to_max_upside_minutes DOUBLE PRECISION,
time_to_max_drawdown_minutes DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS backtest_runs (
run_id VARCHAR PRIMARY KEY,
run_timestamp TIMESTAMP,
strategy_name VARCHAR,
strategy_version VARCHAR,
feature_version VARCHAR,
date_range_start DATE,
date_range_end DATE,
parameter_config_json VARCHAR,
summary_metrics_json VARCHAR,
total_trades INTEGER,
win_rate DOUBLE PRECISION,
expectancy DOUBLE PRECISION,
max_drawdown DOUBLE PRECISION,
profit_factor DOUBLE PRECISION
);
-- =====================================================================
-- Paper trading
-- =====================================================================
CREATE TABLE IF NOT EXISTS simulated_orders (
order_id VARCHAR PRIMARY KEY,
session_id VARCHAR,
symbol VARCHAR,
created_at TIMESTAMP,
side VARCHAR,
order_type VARCHAR,
qty INTEGER,
intended_entry DOUBLE PRECISION,
stop_price DOUBLE PRECISION,
target_price DOUBLE PRECISION,
status VARCHAR,
strategy_name VARCHAR,
setup_id VARCHAR
);
CREATE TABLE IF NOT EXISTS simulated_fills (
fill_id VARCHAR PRIMARY KEY,
order_id VARCHAR,
symbol VARCHAR,
fill_time TIMESTAMP,
fill_price DOUBLE PRECISION,
qty INTEGER,
slippage DOUBLE PRECISION,
commission DOUBLE PRECISION,
venue VARCHAR
);
CREATE TABLE IF NOT EXISTS strategy_runs (
run_id VARCHAR PRIMARY KEY,
started_at TIMESTAMP,
ended_at TIMESTAMP,
mode VARCHAR,
strategy_name VARCHAR,
strategy_version VARCHAR,
config_json VARCHAR,
summary_json VARCHAR
);
CREATE TABLE IF NOT EXISTS paper_sessions (
session_id VARCHAR PRIMARY KEY,
trade_date DATE,
started_at TIMESTAMP,
ended_at TIMESTAMP,
starting_equity DOUBLE PRECISION,
ending_equity DOUBLE PRECISION,
realized_pnl DOUBLE PRECISION,
unrealized_pnl DOUBLE PRECISION,
total_orders INTEGER,
total_fills INTEGER,
total_setups INTEGER,
notes VARCHAR
);
CREATE TABLE IF NOT EXISTS paper_missed_trades (
audit_id VARCHAR PRIMARY KEY,
session_id VARCHAR,
symbol VARCHAR,
setup_id VARCHAR,
reason VARCHAR,
setup_time TIMESTAMP,
evaluated_at TIMESTAMP,
attempted_entry_time TIMESTAMP,
entry_cutoff VARCHAR,
minutes_past_cutoff DOUBLE PRECISION,
success_score_pct DOUBLE PRECISION,
criteria_passed INTEGER,
criteria_total INTEGER,
provider_status VARCHAR,
provider_degraded_count INTEGER,
source_status VARCHAR,
source_degraded_count INTEGER,
source_backoff_count INTEGER,
created_at TIMESTAMP,
details_json VARCHAR
);
-- =====================================================================
-- Raw landing (append-only) + telemetry + scanner snapshots
-- =====================================================================
CREATE TABLE IF NOT EXISTS raw_fetch_attempts (
id VARCHAR PRIMARY KEY,
source VARCHAR NOT NULL,
capability VARCHAR NOT NULL,
request_window_start TIMESTAMP,
request_window_end TIMESTAMP,
fetched_at TIMESTAMP NOT NULL,
ingest_run_id VARCHAR,
http_status INTEGER,
item_count INTEGER,
error_msg VARCHAR,
payload_hash VARCHAR,
parser_version VARCHAR
);
CREATE INDEX IF NOT EXISTS idx_raw_fetch_attempts_source_window
ON raw_fetch_attempts (source, capability, request_window_start, request_window_end);
CREATE INDEX IF NOT EXISTS idx_raw_fetch_attempts_ingest_run
ON raw_fetch_attempts (ingest_run_id);
CREATE TABLE IF NOT EXISTS raw_news_items (
id VARCHAR PRIMARY KEY,
fetch_attempt_id VARCHAR,
source VARCHAR,
raw_url VARCHAR,
raw_title VARCHAR,
raw_published_at VARCHAR,
raw_body_snippet VARCHAR,
raw_tickers VARCHAR,
payload_hash VARCHAR,
parser_version VARCHAR,
fetched_at TIMESTAMP,
ingest_run_id VARCHAR
);
CREATE INDEX IF NOT EXISTS idx_raw_news_items_fetch_attempt
ON raw_news_items (fetch_attempt_id);
CREATE INDEX IF NOT EXISTS idx_raw_news_items_source_published
ON raw_news_items (source, raw_published_at);
CREATE TABLE IF NOT EXISTS telemetry_events (
id VARCHAR PRIMARY KEY,
event_type VARCHAR,
session_id VARCHAR,
symbol VARCHAR,
source VARCHAR,
from_state VARCHAR,
to_state VARCHAR,
reason VARCHAR,
metadata_json VARCHAR,
created_at TIMESTAMP DEFAULT current_timestamp
);
CREATE TABLE IF NOT EXISTS scanner_snapshots (
id VARCHAR PRIMARY KEY,
snapshot_time TIMESTAMP,
symbol VARCHAR,
rank INTEGER,
price DOUBLE PRECISION,
gap_pct DOUBLE PRECISION,
premarket_gap_pct DOUBLE PRECISION,
cumulative_volume BIGINT,
minute_volume BIGINT,
premarket_volume BIGINT,
relative_volume DOUBLE PRECISION,
float_rotation_pct DOUBLE PRECISION,
vwap DOUBLE PRECISION,
distance_from_vwap DOUBLE PRECISION,
ema9 DOUBLE PRECISION,
ema20 DOUBLE PRECISION,
intraday_range DOUBLE PRECISION,
intraday_range_vs_atr DOUBLE PRECISION,
distance_to_premarket_high DOUBLE PRECISION,
distance_to_premarket_low DOUBLE PRECISION,
distance_to_day_high DOUBLE PRECISION,
distance_to_day_low DOUBLE PRECISION,
news_flag BOOLEAN,
catalyst_freshness_minutes DOUBLE PRECISION,
catalyst_type VARCHAR,
float_bucket VARCHAR,
market_cap_bucket VARCHAR,
spread_pct DOUBLE PRECISION,
halt_flag BOOLEAN,
dilution_risk_flag BOOLEAN,
sympathy_move_candidate_flag BOOLEAN,
lead_runner_symbol VARCHAR,
sympathy_cluster_id VARCHAR,
early_leader_flag BOOLEAN,
rank_stability DOUBLE PRECISION,
momentum_score DOUBLE PRECISION,
scanner_version VARCHAR
);
-- =====================================================================
-- Late-added columns (parity with storage/schema.py:create_schema)
-- Postgres supports ADD COLUMN IF NOT EXISTS (9.6+).
-- =====================================================================
ALTER TABLE setup_events ADD COLUMN IF NOT EXISTS tape_regime_at_trigger VARCHAR;
ALTER TABLE setup_events ADD COLUMN IF NOT EXISTS spy_intraday_return_pct_at_trigger DOUBLE PRECISION;
+530
View File
@@ -0,0 +1,530 @@
import json
"""Projections for Milestone 2 read models.
Aggregated views for querying sessions, symbols, approvals, and positions.
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
def query_session_summary(store) -> list[dict]:
"""Query all session summaries from events."""
events = store.query_events(event_type="session_summary")
summaries = []
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
if "session_id" in payload:
summaries.append(
{
"session_id": payload["session_id"],
"event_id": event["id"],
"timestamp": event["timestamp"],
}
)
return summaries
def query_symbol_status(store, symbol: str | None = None) -> list[dict]:
"""Query current status for symbols."""
events = store.query_events(
event_type="symbol_state_changed",
symbol=symbol,
)
return [
{
"event_id": event["id"],
"symbol": json.loads(event.get("payload_json", "{}")).get("symbol"),
"previous_state": json.loads(event.get("payload_json", "{}")).get(
"previous_state"
),
"new_state": json.loads(event.get("payload_json", "{}")).get("new_state"),
"timestamp": event["timestamp"],
}
for event in events
]
def query_approval_queue(store) -> list[dict]:
requested = store.query_events(event_type="order_approval_requested", limit=None)
approved = store.query_events(event_type="order_approved", limit=None)
rejected = store.query_events(event_type="order_rejected", limit=None)
submitted = store.query_events(event_type="order_submitted", limit=None)
cancelled = store.query_events(event_type="order_cancelled", limit=None)
resolved_ids = {
str(json.loads(event.get("payload_json", "{}")).get("order_id"))
for event in approved + rejected + submitted + cancelled
if json.loads(event.get("payload_json", "{}")).get("order_id") is not None
}
queue = []
for event in requested:
payload = json.loads(event.get("payload_json", "{}"))
order_id = payload.get("order_id")
if order_id is None or str(order_id) in resolved_ids:
continue
queue.append(
{
"event_id": event["id"],
"order_id": str(order_id),
"symbol": payload.get("symbol"),
"requested_by": payload.get("requested_by"),
"approval_mode": payload.get("approval_mode"),
"execution_mode": payload.get("execution_mode"),
"execution_request": payload.get("execution_request", {}),
"timestamp": event["timestamp"],
}
)
return queue
def query_positions_view(store) -> list[dict]:
"""Query all positions (opened/closed events)."""
opened = store.query_events(event_type="position_opened")
closed = store.query_events(event_type="position_closed")
position_events = opened + closed
positions = []
for event in position_events:
payload = json.loads(event.get("payload_json", "{}"))
positions.append(
{
"event_id": event["id"],
"position_id": payload.get("position_id"),
"symbol": payload.get("symbol"),
"event_type": event["event_type"],
"entry_price": payload.get("entry_price"),
"quantity": payload.get("quantity"),
"stop_loss_price": payload.get("stop_loss_price"),
"exit_price": payload.get("exit_price")
if event["event_type"] == "position_closed"
else None,
"exit_reason": payload.get("exit_reason")
if event["event_type"] == "position_closed"
else None,
"realized_pnl": payload.get("realized_pnl")
if event["event_type"] == "position_closed"
else None,
"timestamp": event["timestamp"],
}
)
return positions
def query_event_timeline(store, session_id: str | None = None) -> list[dict]:
"""Query full event timeline for a session."""
return store.query_events(session_id=session_id, limit=None)
def query_settings_snapshot(store) -> dict:
"""Query current settings snapshot."""
events = store.query_events(event_type="settings_snapshot")
if not events:
return {}
latest_event = events[-1]
return json.loads(latest_event.get("payload_json", "{}"))
def query_ready_signals_snapshot(store, session_id: str | None = None) -> list[dict]:
"""Latest signal_ready per symbol, newest first. Feeds the dashboard."""
events = store.query_events(
event_type="signal_ready", session_id=session_id, limit=None
)
latest_by_symbol: dict[str, dict] = {}
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
symbol = payload.get("symbol")
if not symbol:
continue
signal_data = payload.get("signal_data") or {}
latest_by_symbol[str(symbol)] = {
"symbol": str(symbol),
"signal_type": payload.get("signal_type"),
"confidence": payload.get("confidence"),
"entry_price": signal_data.get("entry_price"),
"stop_loss_price": signal_data.get("stop_loss_price"),
"quality_score": signal_data.get("quality_score"),
"timestamp": event.get("timestamp"),
}
return sorted(
latest_by_symbol.values(),
key=lambda row: str(row.get("timestamp") or ""),
reverse=True,
)
def query_watch_states_snapshot(store, session_id: str | None = None) -> list[dict]:
state_events = store.query_events(
event_type="symbol_state_changed",
session_id=session_id,
limit=None,
)
criteria_events = store.query_events(
event_type="criteria_evaluated",
session_id=session_id,
limit=None,
)
latest_by_symbol: dict[str, dict] = {}
def _blank(symbol_key: str) -> dict:
return {
"symbol": symbol_key,
"state": None,
"previous_state": None,
"state_reason": None,
"last_transition_at": None,
"last_score": None,
"last_criteria_at": None,
}
for event in criteria_events:
payload = json.loads(event.get("payload_json", "{}"))
symbol = payload.get("symbol")
if not symbol:
continue
symbol_key = str(symbol)
entry = latest_by_symbol.setdefault(symbol_key, _blank(symbol_key))
entry["last_score"] = payload.get("success_score_pct")
entry["last_criteria_at"] = event.get("timestamp")
for event in state_events:
payload = json.loads(event.get("payload_json", "{}"))
symbol = payload.get("symbol")
if not symbol:
continue
symbol_key = str(symbol)
entry = latest_by_symbol.setdefault(symbol_key, _blank(symbol_key))
entry["state"] = payload.get("new_state")
entry["previous_state"] = payload.get("previous_state")
entry["state_reason"] = payload.get("state_reason")
entry["last_transition_at"] = event.get("timestamp")
return sorted(latest_by_symbol.values(), key=lambda row: row["symbol"])
def query_account_summary_snapshot(store, broker_name: str | None = None) -> list[dict]:
events = store.query_events(event_type="account_summary_updated", limit=None)
latest_by_account: dict[tuple[str, str], dict] = {}
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
broker = str(payload.get("broker_name") or "")
account_id = str(payload.get("account_id") or "")
if not broker or not account_id:
continue
if broker_name is not None and broker != broker_name:
continue
latest_by_account[(broker, account_id)] = {
"broker_name": broker,
"account_id": account_id,
"account_desc": payload.get("account_desc"),
"total_equity": payload.get("total_equity"),
"cash_balance": payload.get("cash_balance"),
"buying_power": payload.get("buying_power"),
"net_liquidating_value": payload.get("net_liquidating_value"),
"timestamp": event.get("timestamp"),
}
return sorted(
latest_by_account.values(),
key=lambda row: (str(row["broker_name"]), str(row["account_id"])),
)
def query_account_positions_snapshot(
store, broker_name: str | None = None
) -> list[dict]:
events = store.query_events(event_type="account_positions_updated", limit=None)
latest_by_account: dict[tuple[str, str], dict] = {}
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
broker = str(payload.get("broker_name") or "")
account_id = str(payload.get("account_id") or "")
if not broker or not account_id:
continue
if broker_name is not None and broker != broker_name:
continue
latest_by_account[(broker, account_id)] = {
"broker_name": broker,
"account_id": account_id,
"positions": payload.get("positions", []),
"timestamp": event.get("timestamp"),
}
return sorted(
latest_by_account.values(),
key=lambda row: (str(row["broker_name"]), str(row["account_id"])),
)
def query_account_orders_snapshot(store, broker_name: str | None = None) -> list[dict]:
events = store.query_events(event_type="account_orders_updated", limit=None)
latest_by_account: dict[tuple[str, str], dict] = {}
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
broker = str(payload.get("broker_name") or "")
account_id = str(payload.get("account_id") or "")
if not broker or not account_id:
continue
if broker_name is not None and broker != broker_name:
continue
latest_by_account[(broker, account_id)] = {
"broker_name": broker,
"account_id": account_id,
"orders": payload.get("orders", []),
"timestamp": event.get("timestamp"),
}
return sorted(
latest_by_account.values(),
key=lambda row: (str(row["broker_name"]), str(row["account_id"])),
)
def query_order_lifecycle_snapshot(store) -> list[dict]:
event_types = [
"order_submitted",
"order_approval_requested",
"order_approved",
"order_rejected",
"order_filled",
"order_cancelled",
]
latest_by_order: dict[str, dict] = {}
for event_type in event_types:
events = store.query_events(event_type=event_type, limit=None)
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
order_id = payload.get("order_id")
if not order_id:
continue
entry = latest_by_order.setdefault(
str(order_id),
{
"order_id": str(order_id),
"symbol": payload.get("symbol"),
"status": None,
"submitted_at": None,
"updated_at": None,
"side": None,
"quantity": None,
"price": None,
"fill_price": None,
"fill_quantity": None,
"approval_mode": None,
"rejection_reason": None,
},
)
if event_type == "order_submitted":
entry["status"] = "submitted"
entry["submitted_at"] = event.get("timestamp")
entry["side"] = payload.get("side")
entry["quantity"] = payload.get("quantity")
entry["price"] = payload.get("price")
elif event_type == "order_approval_requested":
entry["status"] = "approval_requested"
entry["approval_mode"] = payload.get("approval_mode")
elif event_type == "order_approved":
entry["status"] = "approved"
elif event_type == "order_rejected":
entry["status"] = "rejected"
entry["rejection_reason"] = payload.get("rejection_reason")
elif event_type == "order_filled":
entry["status"] = "filled"
entry["fill_price"] = payload.get("fill_price")
entry["fill_quantity"] = payload.get("fill_quantity")
elif event_type == "order_cancelled":
entry["status"] = "cancelled"
entry["rejection_reason"] = payload.get("cancel_reason")
entry["updated_at"] = event.get("timestamp")
return sorted(latest_by_order.values(), key=lambda row: row["order_id"])
# ---------------------------------------------------------------------------
# Interactive dashboard projections (criteria detail, session P&L, fills feed)
# ---------------------------------------------------------------------------
# Human-readable labels for the nine setup criteria, in evaluation order.
CRITERIA_LABELS = {
"sufficient_data": "Sufficient data",
"gap": "Gap %",
"relative_volume": "Relative volume",
"impulse": "Impulse leg",
"pullback": "Pullback depth",
"pullback_volume": "Pullback volume",
"vwap": "Holding VWAP",
"candle_quality": "Candle quality",
"breakout": "Breakout trigger",
}
def query_symbol_criteria(store, symbol: str, session_id: str | None = None) -> dict:
"""Latest per-criterion pass/fail breakdown for one symbol.
Powers the click-to-expand criteria panel: returns every criterion with a
passed flag and label so a 'blocked' symbol becomes legible at a glance.
"""
events = store.query_events(
event_type="criteria_evaluated",
symbol=symbol,
session_id=session_id,
limit=None,
)
if not events:
return {"symbol": symbol, "criteria": [], "score": None, "evaluated_at": None}
latest = events[-1]
payload = json.loads(latest.get("payload_json", "{}"))
results = payload.get("criteria_results") or {}
passed = set(results.get("passed") or [])
failed = set(results.get("failed") or [])
criteria = []
for key, label in CRITERIA_LABELS.items():
if key in passed:
state = True
elif key in failed:
state = False
else:
state = None # not evaluated this pass
criteria.append({"key": key, "label": label, "passed": state})
return {
"symbol": symbol,
"criteria": criteria,
"passed_count": payload.get("passed_criteria"),
"total_count": payload.get("total_criteria"),
"score": payload.get("success_score_pct"),
"status": payload.get("status"),
"evaluated_at": latest.get("timestamp"),
}
def query_fills_feed(store, limit: int = 50) -> list[dict]:
"""Chronological fills + submissions for the activity feed (newest first)."""
rows: list[dict] = []
for event_type in ("order_filled", "order_submitted"):
for event in store.query_events(event_type=event_type, limit=None):
payload = json.loads(event.get("payload_json", "{}"))
rows.append(
{
"kind": event_type,
"order_id": payload.get("order_id"),
"symbol": payload.get("symbol"),
"side": payload.get("side"),
"quantity": payload.get("fill_quantity") or payload.get("quantity"),
"price": payload.get("fill_price") or payload.get("price"),
"timestamp": event.get("timestamp"),
}
)
rows.sort(key=lambda r: str(r.get("timestamp") or ""), reverse=True)
return rows[:limit]
def _r_multiple(entry, stop, exit_price, side: str) -> float | None:
"""Realized R-multiple given entry/stop/exit. None if stop distance is 0."""
try:
entry = float(entry)
stop = float(stop)
exit_price = float(exit_price)
except (TypeError, ValueError):
return None
risk = abs(entry - stop)
if risk <= 0:
return None
direction = 1.0 if side != "sell" else -1.0
return round((exit_price - entry) * direction / risk, 2)
def query_session_pnl(store, session_id: str | None = None) -> dict:
"""Day P&L + trade stats from position_closed events and live positions.
Realized stats come from closed positions; unrealized comes from the most
recent account positions snapshot so the strip matches the broker.
"""
closed = store.query_events(event_type="position_closed", limit=None)
realized = 0.0
wins = 0
losses = 0
r_multiples: list[float] = []
trades: list[dict] = []
for event in closed:
payload = json.loads(event.get("payload_json", "{}"))
pnl = payload.get("realized_pnl")
if pnl is None:
continue
pnl = float(pnl)
realized += pnl
if pnl > 0:
wins += 1
elif pnl < 0:
losses += 1
r = _r_multiple(
payload.get("entry_price"),
payload.get("stop_loss_price"),
payload.get("exit_price"),
payload.get("side") or "buy",
)
if r is not None:
r_multiples.append(r)
trades.append(
{
"symbol": payload.get("symbol"),
"realized_pnl": round(pnl, 2),
"r_multiple": r,
"exit_reason": payload.get("exit_reason"),
"timestamp": event.get("timestamp"),
}
)
# unrealized from the latest positions snapshot (any broker)
unrealized = 0.0
open_positions = 0
snapshots = query_account_positions_snapshot(store)
if snapshots:
for position in snapshots[-1].get("positions") or []:
open_positions += 1
try:
unrealized += float(position.get("unrealized_pl") or 0.0)
except (TypeError, ValueError):
pass
closed_count = wins + losses
win_rate = round(wins / closed_count, 3) if closed_count else None
avg_r = round(sum(r_multiples) / len(r_multiples), 2) if r_multiples else None
return {
"realized_pnl": round(realized, 2),
"unrealized_pnl": round(unrealized, 2),
"total_pnl": round(realized + unrealized, 2),
"wins": wins,
"losses": losses,
"closed_trades": closed_count,
"open_positions": open_positions,
"win_rate": win_rate,
"avg_r_multiple": avg_r,
"expectancy_r": avg_r,
"trades": list(reversed(trades))[:20],
}
+148
View File
@@ -0,0 +1,148 @@
import json
"""Replay/rebuild tooling for Milestone 2.
Reconstruct state from event store for analysis and testing.
"""
import logging
from typing import TYPE_CHECKING
from .event_store import EventStore
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class StateRebuilder:
"""Rebuild trading state from event store."""
def __init__(self, store: EventStore):
self.store = store
def rebuild_session(self, session_id: str) -> dict:
"""Rebuild complete state for a session from events."""
logger.info(f"Rebuilding session {session_id}")
events = self.store.query_events(session_id=session_id)
state = {
"session_id": session_id,
"symbols": {},
"orders": {},
"positions": {},
"events": [],
}
for event in events:
event_type = event["event_type"]
payload = json.loads(event.get("payload_json", "{}"))
symbol = payload.get("symbol")
self._process_event(state, event_type, symbol, payload, event)
logger.info(f"Rebuilt state for {len(events)} events")
return state
def _process_event(
self,
state: dict,
event_type: str,
symbol: str | None,
payload: dict,
event: dict,
):
"""Process individual event and update state."""
if event_type == "symbol_discovered":
state["symbols"][symbol] = {
"state": "discovered",
"discovered_at": event["timestamp"],
}
elif event_type == "symbol_state_changed":
if symbol not in state["symbols"]:
state["symbols"][symbol] = {
"state": payload.get("previous_state") or "discovered",
"discovered_at": event["timestamp"],
}
state["symbols"][symbol]["state"] = payload["new_state"]
state["symbols"][symbol]["state_history"] = state["symbols"][symbol].get(
"state_history", []
)
state["symbols"][symbol]["state_history"].append(
{
"state": payload["new_state"],
"timestamp": event["timestamp"],
"reason": payload.get("state_reason"),
}
)
elif event_type == "signal_ready":
if symbol in state["symbols"]:
state["symbols"][symbol]["signals"] = state["symbols"][symbol].get(
"signals", []
)
state["symbols"][symbol]["signals"].append(
{
"signal_type": payload["signal_type"],
"confidence": payload["confidence"],
"timestamp": event["timestamp"],
}
)
elif event_type == "position_opened":
position_id = payload["position_id"]
state["positions"][position_id] = {
"symbol": symbol,
"status": "open",
"entry_price": payload["entry_price"],
"quantity": payload["quantity"],
"stop_loss_price": payload["stop_loss_price"],
"opened_at": event["timestamp"],
}
elif event_type == "position_closed":
position_id = payload["position_id"]
if position_id in state["positions"]:
state["positions"][position_id].update(
{
"status": "closed",
"exit_price": payload["exit_price"],
"exit_reason": payload["exit_reason"],
"realized_pnl": payload["realized_pnl"],
"closed_at": event["timestamp"],
}
)
elif event_type == "order_filled":
state["events"].append(
{
"type": event_type,
"order_id": payload["order_id"],
"symbol": symbol,
"timestamp": event["timestamp"],
}
)
elif event_type == "order_submitted":
order_id = payload["order_id"]
state["orders"][order_id] = {
"symbol": symbol,
"status": "submitted",
"side": payload.get("side"),
"quantity": payload.get("quantity"),
"price": payload.get("price"),
"updated_at": event["timestamp"],
}
elif event_type == "order_cancelled":
order_id = payload["order_id"]
state["orders"][order_id] = {
**state["orders"].get(order_id, {}),
"symbol": symbol,
"status": "cancelled",
"cancel_reason": payload.get("cancel_reason"),
"updated_at": event["timestamp"],
}
+444
View File
@@ -0,0 +1,444 @@
"""DuckDB schema definitions for all tables.
Milestone 1, Task 1.1
"""
SCHEMA_SQL = """
-- Symbols: universe metadata
CREATE TABLE IF NOT EXISTS symbols (
symbol VARCHAR PRIMARY KEY,
security_name VARCHAR,
exchange VARCHAR,
asset_type VARCHAR DEFAULT 'common_stock',
sector VARCHAR,
industry VARCHAR,
shares_outstanding BIGINT,
float_shares BIGINT,
market_cap DOUBLE,
is_etf BOOLEAN DEFAULT FALSE,
is_otc BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT current_timestamp,
updated_at TIMESTAMP DEFAULT current_timestamp
);
-- Trading days: market calendar
CREATE TABLE IF NOT EXISTS trading_days (
trade_date DATE PRIMARY KEY,
session_open TIMESTAMP,
session_close TIMESTAMP,
premarket_open TIMESTAMP,
afterhours_close TIMESTAMP,
is_half_day BOOLEAN DEFAULT FALSE,
is_holiday_adjacent BOOLEAN DEFAULT FALSE,
tape_regime VARCHAR, -- strong / neutral / weak
market_regime VARCHAR, -- bull / neutral / bear
volatility_regime VARCHAR, -- low / medium / high
gap_activity_level INTEGER,
momentum_density INTEGER
);
-- Daily bars
CREATE TABLE IF NOT EXISTS daily_bars (
symbol VARCHAR,
trade_date DATE,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume BIGINT,
vwap DOUBLE,
previous_close DOUBLE,
true_range DOUBLE,
atr_14 DOUBLE,
rolling_avg_volume_20d DOUBLE,
rolling_avg_volume_50d DOUBLE,
prior_day_gain_pct DOUBLE,
multi_day_runner_flag BOOLEAN DEFAULT FALSE,
PRIMARY KEY (symbol, trade_date)
);
-- Minute bars
CREATE TABLE IF NOT EXISTS minute_bars (
symbol VARCHAR,
timestamp TIMESTAMP,
session_date DATE,
is_premarket BOOLEAN,
is_regular_hours BOOLEAN,
is_afterhours BOOLEAN,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume BIGINT,
vwap DOUBLE,
spread_pct DOUBLE,
halt_status BOOLEAN DEFAULT FALSE,
source_provider VARCHAR DEFAULT 'synthetic',
quality_score DOUBLE,
PRIMARY KEY (symbol, timestamp)
);
-- News / catalyst events
CREATE TABLE IF NOT EXISTS news_events (
id VARCHAR,
symbol VARCHAR,
headline VARCHAR,
source VARCHAR,
published_at TIMESTAMP,
catalyst_freshness_minutes DOUBLE,
category VARCHAR,
sentiment DOUBLE,
is_earnings BOOLEAN DEFAULT FALSE,
is_offering BOOLEAN DEFAULT FALSE,
is_halt_related BOOLEAN DEFAULT FALSE,
PRIMARY KEY (id)
);
-- Halt events
CREATE TABLE IF NOT EXISTS halt_events (
id VARCHAR,
symbol VARCHAR,
halt_start TIMESTAMP,
halt_end TIMESTAMP,
halt_type VARCHAR,
price_at_halt DOUBLE,
resumed_price DOUBLE,
gap_on_resume_pct DOUBLE,
PRIMARY KEY (id)
);
-- Market context (SPY/QQQ/VIX)
CREATE TABLE IF NOT EXISTS market_context (
timestamp TIMESTAMP,
trade_date DATE,
spy_open DOUBLE,
spy_high DOUBLE,
spy_low DOUBLE,
spy_close DOUBLE,
spy_volume BIGINT,
qqq_open DOUBLE,
qqq_close DOUBLE,
spy_intraday_return_pct DOUBLE,
vix_open DOUBLE,
vix_level DOUBLE,
advance_decline_ratio DOUBLE,
up_volume_down_volume_ratio DOUBLE,
tape_regime_flag VARCHAR,
PRIMARY KEY (timestamp)
);
-- Dilution / offering events
CREATE TABLE IF NOT EXISTS dilution_events (
id VARCHAR PRIMARY KEY,
symbol VARCHAR,
event_type VARCHAR,
announced_at TIMESTAMP,
effective_date DATE,
share_count BIGINT,
price_per_share DOUBLE,
gross_proceeds DOUBLE
);
-- Short interest history
CREATE TABLE IF NOT EXISTS short_interest (
symbol VARCHAR,
report_date DATE,
short_interest_shares BIGINT,
short_interest_pct_float DOUBLE,
days_to_cover DOUBLE,
PRIMARY KEY (symbol, report_date)
);
-- Versioned feature rows
CREATE TABLE IF NOT EXISTS engineered_features (
id VARCHAR PRIMARY KEY,
timestamp TIMESTAMP,
symbol VARCHAR,
session_date DATE,
feature_version VARCHAR,
gap_pct DOUBLE,
premarket_gap_pct DOUBLE,
relative_volume DOUBLE,
time_of_day_adjusted_relative_volume DOUBLE,
float_rotation_pct DOUBLE,
vwap DOUBLE,
distance_from_vwap DOUBLE,
ema9 DOUBLE,
ema20 DOUBLE,
pullback_volume_ratio DOUBLE,
pullback_candle_type VARCHAR,
catalyst_freshness_minutes DOUBLE,
catalyst_type VARCHAR,
tape_regime VARCHAR,
spy_intraday_return DOUBLE,
vix_level DOUBLE,
metadata_json VARCHAR
);
-- Setup events
CREATE TABLE IF NOT EXISTS setup_events (
setup_id VARCHAR PRIMARY KEY,
symbol VARCHAR,
setup_time TIMESTAMP,
session_date DATE,
setup_name VARCHAR DEFAULT 'first_pullback',
setup_version VARCHAR DEFAULT 'v1',
entry_reference_price DOUBLE,
invalidation_price DOUBLE,
target_r_multiple DOUBLE DEFAULT 2.0,
impulse_start_price DOUBLE,
impulse_end_price DOUBLE,
impulse_pct DOUBLE,
pullback_low DOUBLE,
pullback_depth_pct DOUBLE,
pullback_bars INTEGER,
pullback_volume_ratio DOUBLE,
above_vwap_flag BOOLEAN,
vwap_at_trigger DOUBLE,
gap_pct DOUBLE,
relative_volume DOUBLE,
float_rotation_at_trigger DOUBLE,
catalyst_freshness_at_trigger DOUBLE,
session_minute_number INTEGER,
tape_regime_at_trigger VARCHAR,
spy_intraday_return_pct_at_trigger DOUBLE
);
-- Outcome labels
CREATE TABLE IF NOT EXISTS outcome_labels (
setup_id VARCHAR PRIMARY KEY,
label_version VARCHAR DEFAULT 'v1',
max_upside_next_5m DOUBLE,
max_upside_next_15m DOUBLE,
max_upside_next_60m DOUBLE,
max_drawdown_next_5m DOUBLE,
max_drawdown_next_15m DOUBLE,
max_drawdown_next_60m DOUBLE,
reached_1r_before_minus_1r BOOLEAN,
reached_2r_before_minus_1r BOOLEAN,
held_vwap_5m BOOLEAN,
held_vwap_15m BOOLEAN,
trend_day_flag BOOLEAN,
failed_breakout_flag BOOLEAN,
time_to_max_upside_minutes DOUBLE,
time_to_max_drawdown_minutes DOUBLE
);
-- Backtest runs
CREATE TABLE IF NOT EXISTS backtest_runs (
run_id VARCHAR PRIMARY KEY,
run_timestamp TIMESTAMP,
strategy_name VARCHAR,
strategy_version VARCHAR,
feature_version VARCHAR,
date_range_start DATE,
date_range_end DATE,
parameter_config_json VARCHAR,
summary_metrics_json VARCHAR,
total_trades INTEGER,
win_rate DOUBLE,
expectancy DOUBLE,
max_drawdown DOUBLE,
profit_factor DOUBLE
);
-- Simulated orders for paper trading
CREATE TABLE IF NOT EXISTS simulated_orders (
order_id VARCHAR PRIMARY KEY,
session_id VARCHAR,
symbol VARCHAR,
created_at TIMESTAMP,
side VARCHAR,
order_type VARCHAR,
qty INTEGER,
intended_entry DOUBLE,
stop_price DOUBLE,
target_price DOUBLE,
status VARCHAR,
strategy_name VARCHAR,
setup_id VARCHAR
);
-- Simulated fills for paper trading
CREATE TABLE IF NOT EXISTS simulated_fills (
fill_id VARCHAR PRIMARY KEY,
order_id VARCHAR,
symbol VARCHAR,
fill_time TIMESTAMP,
fill_price DOUBLE,
qty INTEGER,
slippage DOUBLE,
commission DOUBLE,
venue VARCHAR
);
-- Strategy run metadata for paper/live sessions
CREATE TABLE IF NOT EXISTS strategy_runs (
run_id VARCHAR PRIMARY KEY,
started_at TIMESTAMP,
ended_at TIMESTAMP,
mode VARCHAR,
strategy_name VARCHAR,
strategy_version VARCHAR,
config_json VARCHAR,
summary_json VARCHAR
);
-- Daily paper trading session records
CREATE TABLE IF NOT EXISTS paper_sessions (
session_id VARCHAR PRIMARY KEY,
trade_date DATE,
started_at TIMESTAMP,
ended_at TIMESTAMP,
starting_equity DOUBLE,
ending_equity DOUBLE,
realized_pnl DOUBLE,
unrealized_pnl DOUBLE,
total_orders INTEGER,
total_fills INTEGER,
total_setups INTEGER,
notes VARCHAR
);
-- Live paper-trade missed-window / late-entry audit rows
CREATE TABLE IF NOT EXISTS paper_missed_trades (
audit_id VARCHAR PRIMARY KEY,
session_id VARCHAR,
symbol VARCHAR,
setup_id VARCHAR,
reason VARCHAR,
setup_time TIMESTAMP,
evaluated_at TIMESTAMP,
attempted_entry_time TIMESTAMP,
entry_cutoff VARCHAR,
minutes_past_cutoff DOUBLE,
success_score_pct DOUBLE,
criteria_passed INTEGER,
criteria_total INTEGER,
provider_status VARCHAR,
provider_degraded_count INTEGER,
source_status VARCHAR,
source_degraded_count INTEGER,
source_backoff_count INTEGER,
created_at TIMESTAMP,
details_json VARCHAR
);
-- Raw landing: one row per source+window fetch attempt (append-only)
CREATE TABLE IF NOT EXISTS raw_fetch_attempts (
id VARCHAR PRIMARY KEY,
source VARCHAR NOT NULL,
capability VARCHAR NOT NULL,
request_window_start TIMESTAMP,
request_window_end TIMESTAMP,
fetched_at TIMESTAMP NOT NULL,
ingest_run_id VARCHAR,
http_status INTEGER,
item_count INTEGER,
error_msg VARCHAR,
payload_hash VARCHAR,
parser_version VARCHAR
);
-- Raw landing: one row per raw news item (append-only)
CREATE TABLE IF NOT EXISTS raw_news_items (
id VARCHAR PRIMARY KEY,
fetch_attempt_id VARCHAR,
source VARCHAR,
raw_url VARCHAR,
raw_title VARCHAR,
raw_published_at VARCHAR,
raw_body_snippet VARCHAR,
raw_tickers VARCHAR,
payload_hash VARCHAR,
parser_version VARCHAR,
fetched_at TIMESTAMP,
ingest_run_id VARCHAR
);
-- Telemetry events: structured observability for fetch attempts, state transitions, enrichment outcomes
CREATE TABLE IF NOT EXISTS telemetry_events (
id VARCHAR PRIMARY KEY,
event_type VARCHAR,
session_id VARCHAR,
symbol VARCHAR,
source VARCHAR,
from_state VARCHAR,
to_state VARCHAR,
reason VARCHAR,
metadata_json VARCHAR,
created_at TIMESTAMP DEFAULT current_timestamp
);
-- Scanner Snapshots - Full scan state for historical tracking
CREATE TABLE IF NOT EXISTS scanner_snapshots (
id VARCHAR PRIMARY KEY,
snapshot_time TIMESTAMP,
symbol VARCHAR,
rank INTEGER,
price DOUBLE,
gap_pct DOUBLE,
premarket_gap_pct DOUBLE,
cumulative_volume BIGINT,
minute_volume BIGINT,
premarket_volume BIGINT,
relative_volume DOUBLE,
float_rotation_pct DOUBLE,
vwap DOUBLE,
distance_from_vwap DOUBLE,
ema9 DOUBLE,
ema20 DOUBLE,
intraday_range DOUBLE,
intraday_range_vs_atr DOUBLE,
distance_to_premarket_high DOUBLE,
distance_to_premarket_low DOUBLE,
distance_to_day_high DOUBLE,
distance_to_day_low DOUBLE,
news_flag BOOLEAN,
catalyst_freshness_minutes DOUBLE,
catalyst_type VARCHAR,
float_bucket VARCHAR,
market_cap_bucket VARCHAR,
spread_pct DOUBLE,
halt_flag BOOLEAN,
dilution_risk_flag BOOLEAN,
sympathy_move_candidate_flag BOOLEAN,
lead_runner_symbol VARCHAR,
sympathy_cluster_id VARCHAR,
early_leader_flag BOOLEAN,
rank_stability DOUBLE,
momentum_score DOUBLE,
scanner_version VARCHAR
);
"""
def create_schema(con) -> None:
"""Create all tables in the DuckDB connection."""
for statement in SCHEMA_SQL.split(";"):
stmt = statement.strip()
if stmt:
con.execute(stmt)
con.execute(
"ALTER TABLE setup_events ADD COLUMN IF NOT EXISTS tape_regime_at_trigger VARCHAR"
)
con.execute(
"ALTER TABLE setup_events ADD COLUMN IF NOT EXISTS spy_intraday_return_pct_at_trigger DOUBLE"
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_fetch_attempts_source_window "
"ON raw_fetch_attempts (source, capability, request_window_start, request_window_end)"
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_fetch_attempts_ingest_run "
"ON raw_fetch_attempts (ingest_run_id)"
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_news_items_fetch_attempt "
"ON raw_news_items (fetch_attempt_id)"
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_news_items_source_published "
"ON raw_news_items (source, raw_published_at)"
)
+11
View File
@@ -0,0 +1,11 @@
"""Strategy core extraction package.
Milestone 1 deliverables:
- Pure setup evaluation package
- Risk/cutoff logic package
- Deterministic tests for ready/blocked/late cases
This package contains pure trading logic with no broker or UI dependency.
"""
__version__ = "0.1.0"
+6
View File
@@ -0,0 +1,6 @@
"""Backtest package: deterministic engine and outcome labeling."""
from strategy.backtest.engine import BacktestEngine, BacktestResult, Trade
from strategy.backtest.outcomes import label_outcome, OutcomeLabel
__all__ = ["BacktestEngine", "BacktestResult", "Trade", "label_outcome", "OutcomeLabel"]
+197
View File
@@ -0,0 +1,197 @@
"""Deterministic intraday backtest engine over minute bars.
Walks bars chronologically, evaluates setups, fills at next bar open with
configurable slippage/commission, and exits on stop, target, or session end.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
import pandas as pd
from config import BacktestConfig, Config
from strategy.evaluation.setup_evaluator import evaluate_setup
from strategy.risk.position_sizing import PositionSizingConfig, calculate_position_size
@dataclass
class Trade:
trade_id: str
symbol: str
entry_time: str
entry_price: float
stop_price: float
target_price: float
quantity: int
exit_time: str | None = None
exit_price: float | None = None
exit_reason: str | None = None
realized_pnl: float | None = None
r_multiple: float | None = None
@dataclass
class BacktestResult:
symbol: str
trades: list[Trade] = field(default_factory=list)
total_pnl: float = 0.0
win_rate: float = 0.0
evaluations: int = 0
signals: int = 0
def summary(self) -> dict:
wins = [t for t in self.trades if (t.realized_pnl or 0) > 0]
return {
"symbol": self.symbol,
"trades": len(self.trades),
"wins": len(wins),
"win_rate": round(self.win_rate, 3),
"total_pnl": round(self.total_pnl, 2),
"evaluations": self.evaluations,
"signals": self.signals,
}
class BacktestEngine:
"""Single-symbol intraday backtest over a session of minute bars."""
def __init__(
self,
config: Config | None = None,
equity: float = 100_000.0,
target_r: float = 2.0,
warmup_bars: int = 15,
eval_every: int = 5,
):
self.config = config or Config()
self.bt: BacktestConfig = self.config.backtest
self.equity = equity
self.target_r = target_r
self.warmup_bars = warmup_bars
self.eval_every = eval_every
def _entry_costs(self, price: float, quantity: int) -> tuple[float, float]:
slip = price * self.bt.slippage.base_spread_pct
commission = max(
self.bt.commission.minimum, quantity * self.bt.commission.per_share
)
return slip, commission
def run(
self,
bars: pd.DataFrame,
symbol: str,
previous_close: float | None = None,
avg_daily_volume: float | None = None,
) -> BacktestResult:
result = BacktestResult(symbol=symbol)
bars = bars.reset_index(drop=True)
open_trade: Trade | None = None
i = self.warmup_bars
while i < len(bars) - 1:
bar = bars.iloc[i]
if open_trade is not None:
low, high = float(bar["low"]), float(bar["high"])
exit_price = None
reason = None
if low <= open_trade.stop_price:
exit_price, reason = open_trade.stop_price, "stop_loss"
elif high >= open_trade.target_price:
exit_price, reason = open_trade.target_price, "target"
elif i == len(bars) - 2:
exit_price, reason = float(bar["close"]), "session_end"
if exit_price is not None:
self._close(open_trade, bar, exit_price, reason, result)
open_trade = None
i += 1
continue
if i % self.eval_every == 0:
window = bars.iloc[: i + 1]
# Evaluate as of the current bar's timestamp, not wall-clock
# now() — otherwise every setup is judged against the real
# clock and marked "late" past the entry cutoff (so a Sunday
# backtest would show zero trades). Bars are stored UTC-naive;
# the cutoff reasons in US/Eastern, so convert.
eval_time = None
try:
from datetime import timezone as _tz
from zoneinfo import ZoneInfo as _ZI
last_ts = pd.Timestamp(window["timestamp"].iloc[-1]).to_pydatetime()
if last_ts.tzinfo is None:
last_ts = last_ts.replace(tzinfo=_tz.utc)
eval_time = last_ts.astimezone(_ZI("America/New_York")).replace(
tzinfo=None
)
except Exception: # noqa: BLE001 - fall back to now()
eval_time = None
evaluation = evaluate_setup(
window,
previous_close=previous_close,
avg_daily_volume=avg_daily_volume,
evaluation_time=eval_time,
)
result.evaluations += 1
if evaluation.status == "ready" and evaluation.setups:
result.signals += 1
setup = evaluation.setups[0]
next_bar = bars.iloc[i + 1]
raw_entry = (
float(next_bar["open"])
if self.bt.entry_mode == "next_bar"
else float(bar["close"])
)
stop = float(setup["stop_loss_price"])
sizing = calculate_position_size(
raw_entry, stop, equity=self.equity,
config=PositionSizingConfig(),
)
if sizing.position_size > 0:
slip, commission = self._entry_costs(
raw_entry, sizing.position_size
)
entry = raw_entry + slip
risk = entry - stop
open_trade = Trade(
trade_id=str(uuid.uuid4()),
symbol=symbol,
entry_time=str(next_bar.get("timestamp", i + 1)),
entry_price=round(entry, 4),
stop_price=round(stop, 4),
target_price=round(entry + self.target_r * risk, 4),
quantity=sizing.position_size,
)
open_trade.realized_pnl = -commission
i += 1 # consumed next bar for entry
i += 1
if open_trade is not None:
last = bars.iloc[-1]
self._close(open_trade, last, float(last["close"]), "session_end", result)
if result.trades:
wins = sum(1 for t in result.trades if (t.realized_pnl or 0) > 0)
result.win_rate = wins / len(result.trades)
return result
def _close(
self, trade: Trade, bar, exit_price: float, reason: str | None,
result: BacktestResult,
) -> None:
slip, commission = self._entry_costs(exit_price, trade.quantity)
fill = exit_price - slip
gross = (fill - trade.entry_price) * trade.quantity
trade.exit_time = str(bar.get("timestamp", "end"))
trade.exit_price = round(fill, 4)
trade.exit_reason = reason
trade.realized_pnl = round((trade.realized_pnl or 0.0) + gross - commission, 2)
risk = trade.entry_price - trade.stop_price
trade.r_multiple = round((fill - trade.entry_price) / risk, 3) if risk > 0 else 0.0
result.trades.append(trade)
result.total_pnl += trade.realized_pnl
+67
View File
@@ -0,0 +1,67 @@
"""Outcome labeling for setups (R-multiple based)."""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
@dataclass
class OutcomeLabel:
"""Outcome of a setup given subsequent bars."""
setup_id: str
hit_target: bool
hit_stop: bool
r_multiple: float
max_favorable_r: float
max_adverse_r: float
bars_to_resolution: int
label: str # "win" | "loss" | "scratch" | "unresolved"
def label_outcome(
setup_id: str,
entry_price: float,
stop_price: float,
bars_after: pd.DataFrame,
target_r: float = 2.0,
) -> OutcomeLabel:
"""Walk forward through bars to see whether target or stop hit first."""
risk = entry_price - stop_price
if risk <= 0 or bars_after.empty:
return OutcomeLabel(setup_id, False, False, 0.0, 0.0, 0.0, 0, "unresolved")
target_price = entry_price + target_r * risk
max_fav = 0.0
max_adv = 0.0
for i, (_, bar) in enumerate(bars_after.iterrows(), start=1):
high = float(bar["high"])
low = float(bar["low"])
max_fav = max(max_fav, (high - entry_price) / risk)
max_adv = min(max_adv, (low - entry_price) / risk)
stop_hit = low <= stop_price
target_hit = high >= target_price
if stop_hit and target_hit:
# Conservative: assume stop hit first within the bar
return OutcomeLabel(
setup_id, False, True, -1.0, max_fav, max_adv, i, "loss"
)
if stop_hit:
return OutcomeLabel(
setup_id, False, True, -1.0, max_fav, max_adv, i, "loss"
)
if target_hit:
return OutcomeLabel(
setup_id, True, False, target_r, max_fav, max_adv, i, "win"
)
final_r = (float(bars_after["close"].iloc[-1]) - entry_price) / risk
label = "scratch" if abs(final_r) < 0.25 else ("win" if final_r > 0 else "loss")
return OutcomeLabel(
setup_id, False, False, round(final_r, 3), max_fav, max_adv,
len(bars_after), label,
)
+103
View File
@@ -0,0 +1,103 @@
"""Strategy evaluation package.
Implements Ross Cameron style momentum trading strategy:
- Structure detection (bull flags, first pullback, gap & go)
- Key levels tracking (VWAP, premarket high, opening range)
- Setup quality scoring
- Market regime detection
"""
from strategy.evaluation.setup_evaluator import evaluate_setup
from strategy.evaluation.criteria import score_criteria, build_criteria_result
from strategy.evaluation.structure import (
classify_setup,
detect_bull_flag,
detect_first_pullback,
detect_gap_and_go,
detect_hod_break,
StructureDetectionResult,
SetupType,
)
from strategy.evaluation.levels import (
compute_key_levels,
calculate_vwap,
calculate_ema,
KeyLevels,
get_trigger_levels,
get_stop_levels,
)
from strategy.evaluation.quality import (
calculate_setup_quality,
QualityScore,
QualityThresholds,
)
from strategy.evaluation.regime import (
assess_market_regime,
get_regime_adjustment,
should_trade_in_regime,
MarketRegime,
RegimeAssessment,
)
from strategy.evaluation.first_candles import (
calculate_first_candle_features,
classify_opening_strength,
FirstCandleFeatures,
)
from strategy.evaluation.volume_metrics import (
calculate_enhanced_volume_metrics,
calculate_time_of_day_rvol,
calculate_float_rotation,
VolumeMetrics,
)
from strategy.evaluation.data_quality import (
calculate_data_quality_score,
should_trade_symbol,
get_quality_grade,
DataQualityScore,
)
__all__ = [
# Setup evaluation
"evaluate_setup",
"score_criteria",
"build_criteria_result",
# Structure detection
"classify_setup",
"detect_bull_flag",
"detect_first_pullback",
"detect_gap_and_go",
"detect_hod_break",
"StructureDetectionResult",
"SetupType",
# Levels
"compute_key_levels",
"calculate_vwap",
"calculate_ema",
"KeyLevels",
"get_trigger_levels",
"get_stop_levels",
# Quality
"calculate_setup_quality",
"QualityScore",
"QualityThresholds",
# Regime
"assess_market_regime",
"get_regime_adjustment",
"should_trade_in_regime",
"MarketRegime",
"RegimeAssessment",
# First candles
"calculate_first_candle_features",
"classify_opening_strength",
"FirstCandleFeatures",
# Volume metrics
"calculate_enhanced_volume_metrics",
"calculate_time_of_day_rvol",
"calculate_float_rotation",
"VolumeMetrics",
# Data quality
"calculate_data_quality_score",
"should_trade_symbol",
"get_quality_grade",
"DataQualityScore",
]
+26
View File
@@ -0,0 +1,26 @@
"""Criteria scoring logic."""
from typing import TYPE_CHECKING
from strategy.models import CriteriaWeights, CriteriaResult
if TYPE_CHECKING:
pass
def score_criteria(
results: list[CriteriaResult],
weights: CriteriaWeights,
) -> tuple[int, int, float]:
"""Score criteria results and return count, total, and percentage."""
passed = [r for r in results if r.passed]
weighted_sum = sum(getattr(weights, r.name) for r in passed)
total = sum(getattr(weights, r.name) for r in results)
pct = min(100.0, max(0.0, (weighted_sum / total * 100))) if total > 0 else 0.0
return len(passed), len(results), pct
def build_criteria_result(
name: str, passed: bool, reason: str | None = None
) -> CriteriaResult:
"""Build a criteria result."""
return CriteriaResult(name=name, passed=passed, reason=reason)
+81
View File
@@ -0,0 +1,81 @@
"""Data quality scoring for minute-bar coverage and integrity."""
from __future__ import annotations
from dataclasses import dataclass, field
import pandas as pd
@dataclass
class DataQualityScore:
"""Data quality assessment for a symbol's session bars."""
score: float = 0.0
grade: str = "F"
bar_count: int = 0
coverage: float = 0.0
max_gap_minutes: float = 0.0
has_zero_volume_run: bool = False
issues: list[str] = field(default_factory=list)
def calculate_data_quality_score(
bars: pd.DataFrame,
expected_bars: int | None = None,
) -> DataQualityScore:
"""Score bar data 0..1 from coverage, gaps, and volume integrity."""
dq = DataQualityScore()
if bars.empty:
dq.issues.append("no_bars")
return dq
dq.bar_count = len(bars)
expected = expected_bars or max(len(bars), 30)
dq.coverage = min(1.0, len(bars) / expected)
score = dq.coverage
ts = pd.to_datetime(bars["timestamp"]) if "timestamp" in bars.columns else None
if ts is not None and len(ts) > 1:
gaps = ts.diff().dt.total_seconds().fillna(60) / 60.0
dq.max_gap_minutes = float(gaps.max())
if dq.max_gap_minutes > 10:
score -= 0.2
dq.issues.append(f"gap_{dq.max_gap_minutes:.0f}m")
zero_run = (bars["volume"].astype(float) <= 0).rolling(5).sum().max()
if zero_run is not None and zero_run >= 5:
dq.has_zero_volume_run = True
score -= 0.2
dq.issues.append("zero_volume_run")
bad_prices = (
(bars["high"] < bars["low"])
| (bars["close"] <= 0)
| (bars["open"] <= 0)
).sum()
if bad_prices:
score -= 0.3
dq.issues.append(f"bad_prices_{int(bad_prices)}")
dq.score = max(0.0, min(1.0, score))
dq.grade = get_quality_grade(dq.score)
return dq
def get_quality_grade(score: float) -> str:
if score >= 0.9:
return "A"
if score >= 0.75:
return "B"
if score >= 0.6:
return "C"
if score >= 0.4:
return "D"
return "F"
def should_trade_symbol(dq: DataQualityScore, min_score: float = 0.6) -> bool:
"""Whether data quality is sufficient to act on signals."""
return dq.score >= min_score
+61
View File
@@ -0,0 +1,61 @@
"""First-candle features: opening drive strength classification."""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
@dataclass
class FirstCandleFeatures:
"""Features of the first regular-hours candles."""
first_candle_range_pct: float = 0.0
first_candle_body_pct: float = 0.0
first_candle_green: bool = False
first_candle_volume: int = 0
open_above_premarket_high: bool = False
opening_strength: str = "neutral"
def calculate_first_candle_features(
bars: pd.DataFrame,
premarket_high: float | None = None,
n_candles: int = 1,
) -> FirstCandleFeatures:
"""Compute features from the first regular-hours candle(s)."""
feats = FirstCandleFeatures()
if bars.empty:
return feats
if "is_regular_hours" in bars.columns:
rth = bars[bars["is_regular_hours"].astype(bool)]
else:
rth = bars
if rth.empty:
return feats
first = rth.head(max(1, n_candles))
o = float(first["open"].iloc[0])
c = float(first["close"].iloc[-1])
h = float(first["high"].max())
lo = float(first["low"].min())
if o > 0:
feats.first_candle_range_pct = (h - lo) / o
feats.first_candle_body_pct = abs(c - o) / o
feats.first_candle_green = c > o
feats.first_candle_volume = int(first["volume"].sum())
if premarket_high is not None:
feats.open_above_premarket_high = o > premarket_high
feats.opening_strength = classify_opening_strength(feats)
return feats
def classify_opening_strength(feats: FirstCandleFeatures) -> str:
"""Classify opening strength as strong / neutral / weak."""
if feats.first_candle_green and feats.first_candle_body_pct >= 0.02:
return "strong"
if not feats.first_candle_green and feats.first_candle_body_pct >= 0.02:
return "weak"
return "neutral"
+116
View File
@@ -0,0 +1,116 @@
"""Key intraday levels: VWAP, EMAs, premarket high/low, opening range.
Pure functions over OHLCV DataFrames — no broker or UI dependencies.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
@dataclass
class KeyLevels:
"""Computed key levels for a symbol's session."""
vwap: float | None = None
ema_9: float | None = None
ema_20: float | None = None
premarket_high: float | None = None
premarket_low: float | None = None
opening_range_high: float | None = None
opening_range_low: float | None = None
high_of_day: float | None = None
low_of_day: float | None = None
previous_close: float | None = None
extras: dict = field(default_factory=dict)
def calculate_vwap(bars: pd.DataFrame) -> pd.Series:
"""Cumulative volume-weighted average price over the given bars."""
if bars.empty:
return pd.Series(dtype=float)
typical = (bars["high"] + bars["low"] + bars["close"]) / 3.0
vol = bars["volume"].astype(float).clip(lower=0)
cum_vol = vol.cumsum().replace(0, np.nan)
vwap = (typical * vol).cumsum() / cum_vol
return vwap.ffill().fillna(typical)
def calculate_ema(values: pd.Series, period: int) -> pd.Series:
"""Exponential moving average."""
if values.empty:
return pd.Series(dtype=float)
return values.ewm(span=period, adjust=False).mean()
def compute_key_levels(
bars: pd.DataFrame,
previous_close: float | None = None,
opening_range_minutes: int = 5,
) -> KeyLevels:
"""Compute key levels from a session's minute bars.
Args:
bars: minute bars (chronological) with open/high/low/close/volume and
optionally is_premarket / is_regular_hours boolean columns.
previous_close: prior session close, if known.
opening_range_minutes: bars to include in the opening range.
"""
levels = KeyLevels(previous_close=previous_close)
if bars.empty:
return levels
bars = bars.reset_index(drop=True)
vwap_series = calculate_vwap(bars)
levels.vwap = float(vwap_series.iloc[-1])
closes = bars["close"].astype(float)
levels.ema_9 = float(calculate_ema(closes, 9).iloc[-1])
levels.ema_20 = float(calculate_ema(closes, 20).iloc[-1])
levels.high_of_day = float(bars["high"].max())
levels.low_of_day = float(bars["low"].min())
if "is_premarket" in bars.columns:
pm = bars[bars["is_premarket"].astype(bool)]
if not pm.empty:
levels.premarket_high = float(pm["high"].max())
levels.premarket_low = float(pm["low"].min())
if "is_regular_hours" in bars.columns:
rth = bars[bars["is_regular_hours"].astype(bool)]
else:
rth = bars
if not rth.empty:
opening = rth.head(opening_range_minutes)
levels.opening_range_high = float(opening["high"].max())
levels.opening_range_low = float(opening["low"].min())
return levels
def get_trigger_levels(levels: KeyLevels) -> list[tuple[str, float]]:
"""Candidate breakout trigger levels, ordered by priority."""
out: list[tuple[str, float]] = []
if levels.high_of_day is not None:
out.append(("high_of_day", levels.high_of_day))
if levels.premarket_high is not None:
out.append(("premarket_high", levels.premarket_high))
if levels.opening_range_high is not None:
out.append(("opening_range_high", levels.opening_range_high))
return out
def get_stop_levels(levels: KeyLevels, entry_price: float) -> list[tuple[str, float]]:
"""Candidate protective stop levels below the entry, ordered tightest first."""
candidates: list[tuple[str, float]] = []
if levels.vwap is not None and levels.vwap < entry_price:
candidates.append(("vwap", levels.vwap))
if levels.ema_9 is not None and levels.ema_9 < entry_price:
candidates.append(("ema_9", levels.ema_9))
if levels.opening_range_low is not None and levels.opening_range_low < entry_price:
candidates.append(("opening_range_low", levels.opening_range_low))
if levels.low_of_day is not None and levels.low_of_day < entry_price:
candidates.append(("low_of_day", levels.low_of_day))
return sorted(candidates, key=lambda kv: entry_price - kv[1])
+36
View File
@@ -0,0 +1,36 @@
"""Optional LLM (Ollama) enrichment hooks. Disabled unless configured."""
from __future__ import annotations
import json
import logging
import urllib.request
logger = logging.getLogger(__name__)
def summarize_setup_with_llm(
setup: dict,
host: str = "http://localhost:11434",
model: str = "mistral",
timeout: int = 15,
) -> str | None:
"""Ask a local Ollama model to summarize a setup. Returns None on failure."""
prompt = (
"Summarize this momentum trading setup in one sentence:\n"
+ json.dumps(setup, default=str)
)
try:
req = urllib.request.Request(
f"{host}/api/generate",
data=json.dumps(
{"model": model, "prompt": prompt, "stream": False}
).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode())
return body.get("response")
except Exception:
logger.debug("LLM enrichment unavailable", exc_info=True)
return None
+83
View File
@@ -0,0 +1,83 @@
"""Setup quality scoring."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class QualityThresholds:
"""Thresholds used to grade setup quality."""
a_grade: float = 0.80
b_grade: float = 0.65
c_grade: float = 0.50
min_tradeable: float = 0.50
@dataclass
class QualityScore:
"""Composite quality score for a detected setup."""
score: float = 0.0
grade: str = "F"
components: dict = field(default_factory=dict)
tradeable: bool = False
def calculate_setup_quality(
gap_pct: float,
relative_volume: float,
structure_quality: float,
above_vwap: bool,
opening_strength: str = "neutral",
data_quality: float = 1.0,
thresholds: QualityThresholds | None = None,
) -> QualityScore:
"""Blend setup ingredients into one 0..1 quality score.
Weights: gap 20%, RVOL 25%, structure 30%, VWAP 10%,
opening strength 5%, data quality 10%.
"""
thresholds = thresholds or QualityThresholds()
gap_component = min(1.0, max(0.0, gap_pct / 0.10))
rvol_component = min(1.0, max(0.0, relative_volume / 5.0))
structure_component = min(1.0, max(0.0, structure_quality))
vwap_component = 1.0 if above_vwap else 0.0
opening_component = {"strong": 1.0, "neutral": 0.5, "weak": 0.0}.get(
opening_strength, 0.5
)
dq_component = min(1.0, max(0.0, data_quality))
score = (
0.20 * gap_component
+ 0.25 * rvol_component
+ 0.30 * structure_component
+ 0.10 * vwap_component
+ 0.05 * opening_component
+ 0.10 * dq_component
)
if score >= thresholds.a_grade:
grade = "A"
elif score >= thresholds.b_grade:
grade = "B"
elif score >= thresholds.c_grade:
grade = "C"
else:
grade = "F"
return QualityScore(
score=round(score, 4),
grade=grade,
components={
"gap": gap_component,
"relative_volume": rvol_component,
"structure": structure_component,
"vwap": vwap_component,
"opening_strength": opening_component,
"data_quality": dq_component,
},
tradeable=score >= thresholds.min_tradeable,
)
+55
View File
@@ -0,0 +1,55 @@
"""Market regime assessment (hot / normal / cold tape)."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class MarketRegime(str, Enum):
HOT = "hot"
NORMAL = "normal"
COLD = "cold"
@dataclass
class RegimeAssessment:
regime: MarketRegime
score: float
reason: str
def assess_market_regime(
gappers_count: int = 0,
avg_gap_pct: float = 0.0,
spy_change_pct: float = 0.0,
) -> RegimeAssessment:
"""Assess the tape from breadth of gappers and index behavior."""
score = 0.0
score += min(1.0, gappers_count / 10.0) * 0.5
score += min(1.0, max(0.0, avg_gap_pct / 0.15)) * 0.3
score += min(1.0, max(0.0, (spy_change_pct + 0.01) / 0.02)) * 0.2
if score >= 0.65:
regime = MarketRegime.HOT
reason = f"{gappers_count} gappers, avg gap {avg_gap_pct:.1%}"
elif score >= 0.35:
regime = MarketRegime.NORMAL
reason = "moderate breadth"
else:
regime = MarketRegime.COLD
reason = "few gappers / weak tape"
return RegimeAssessment(regime=regime, score=round(score, 3), reason=reason)
def get_regime_adjustment(regime: MarketRegime) -> float:
"""Position-size multiplier for the regime."""
return {MarketRegime.HOT: 1.0, MarketRegime.NORMAL: 0.75, MarketRegime.COLD: 0.5}[
regime
]
def should_trade_in_regime(regime: MarketRegime, allow_cold: bool = False) -> bool:
if regime == MarketRegime.COLD:
return allow_cold
return True
+205
View File
@@ -0,0 +1,205 @@
"""Setup evaluator — the heart of Milestone 1.
Pure function: bars + context in, SetupEvaluationResult (ready/blocked/late) out.
No broker, DB, or UI dependencies.
"""
from __future__ import annotations
from datetime import datetime, time
import pandas as pd
from strategy.models import (
CriteriaWeights,
SetupCriteria,
SetupEvaluationResult,
)
from strategy.evaluation.criteria import build_criteria_result, score_criteria
from strategy.evaluation.levels import compute_key_levels, get_stop_levels
from strategy.evaluation.structure import SetupType, classify_setup
from strategy.evaluation.quality import calculate_setup_quality
from strategy.evaluation.first_candles import calculate_first_candle_features
from strategy.evaluation.volume_metrics import calculate_enhanced_volume_metrics
from strategy.evaluation.data_quality import calculate_data_quality_score
from strategy.risk.entry_cuts import EntryCutoffConfig, check_entry_cutoff
MIN_BARS = 10
def evaluate_setup(
bars: pd.DataFrame,
previous_close: float | None = None,
avg_daily_volume: float | None = None,
criteria: SetupCriteria | None = None,
weights: CriteriaWeights | None = None,
evaluation_time: datetime | None = None,
entry_cutoff: EntryCutoffConfig | None = None,
ready_score_pct: float = 60.0,
) -> SetupEvaluationResult:
"""Evaluate a symbol's session bars for a momentum setup.
Status semantics:
ready — criteria score >= ready_score_pct, valid structure, before cutoff
late — would be ready but the entry cutoff has passed
blocked — anything else (with the dominant blocking reason)
"""
criteria = criteria or SetupCriteria()
weights = weights or CriteriaWeights()
evaluation_time = evaluation_time or datetime.now()
if bars is None or bars.empty or len(bars) < MIN_BARS:
return _blocked(
reason=f"insufficient_data ({0 if bars is None else len(bars)} bars, need {MIN_BARS})",
evaluation_time=evaluation_time,
)
bars = bars.reset_index(drop=True)
price = float(bars["close"].iloc[-1])
open_price = float(bars["open"].iloc[0])
ref_close = previous_close if previous_close and previous_close > 0 else open_price
gap_pct = (open_price - ref_close) / ref_close if ref_close > 0 else 0.0
intraday_change = (price - open_price) / open_price if open_price > 0 else 0.0
dq = calculate_data_quality_score(bars)
vol = calculate_enhanced_volume_metrics(bars, avg_daily_volume=avg_daily_volume)
relative_volume = vol.relative_volume if avg_daily_volume else max(
1.0, vol.last_bar_volume / vol.avg_bar_volume if vol.avg_bar_volume else 1.0
)
levels = compute_key_levels(bars, previous_close=previous_close)
structure = classify_setup(
bars,
premarket_high=levels.premarket_high,
session_high=levels.high_of_day,
min_quality_score=0.2,
)
first = calculate_first_candle_features(bars, premarket_high=levels.premarket_high)
above_vwap = levels.vwap is not None and price >= levels.vwap
results = [
build_criteria_result(
"sufficient_data",
dq.score >= criteria.min_quality_score,
f"quality {dq.score:.2f} (min {criteria.min_quality_score})",
),
build_criteria_result(
"gap",
gap_pct >= criteria.gap_pct_min or intraday_change >= criteria.gap_pct_min,
f"gap {gap_pct:.1%} / intraday {intraday_change:.1%} (min {criteria.gap_pct_min:.0%})",
),
build_criteria_result(
"relative_volume",
relative_volume >= criteria.relative_volume_min,
f"rvol {relative_volume:.1f} (min {criteria.relative_volume_min})",
),
build_criteria_result(
"impulse",
structure.is_valid and structure.setup_type != SetupType.NONE,
f"structure {structure.setup_type.value}: {structure.reason or 'ok'}",
),
build_criteria_result(
"pullback",
structure.setup_type
in (SetupType.FIRST_PULLBACK, SetupType.BULL_FLAG, SetupType.GAP_AND_GO,
SetupType.HOD_BREAK, SetupType.CONTINUATION_FALLBACK)
and structure.is_valid,
"pullback/consolidation structure",
),
build_criteria_result(
"pullback_volume",
structure.quality_score >= 0.3,
f"structure quality {structure.quality_score:.2f}",
),
build_criteria_result("vwap", above_vwap, f"price vs VWAP {levels.vwap}"),
build_criteria_result(
"candle_quality",
first.opening_strength != "weak",
f"opening {first.opening_strength}",
),
build_criteria_result(
"breakout",
structure.breakout_level is not None
and price >= structure.breakout_level * 0.995,
f"price {price:.2f} vs breakout {structure.breakout_level}",
),
]
passed_n, total_n, score_pct = score_criteria(results, weights)
passed_names = [r.name for r in results if r.passed]
failed_names = [r.name for r in results if not r.passed]
quality = calculate_setup_quality(
gap_pct=max(gap_pct, intraday_change),
relative_volume=relative_volume,
structure_quality=structure.quality_score,
above_vwap=above_vwap,
opening_strength=first.opening_strength,
data_quality=dq.score,
)
entry_price = structure.breakout_level or price
stop_candidates = get_stop_levels(levels, entry_price)
stop_price = structure.stop_level or (
stop_candidates[0][1] if stop_candidates else round(entry_price * 0.97, 4)
)
setups = []
if structure.is_valid and structure.setup_type != SetupType.NONE:
setups.append(
{
"setup_type": structure.setup_type.value,
"entry_price": round(float(entry_price), 4),
"stop_loss_price": round(float(stop_price), 4),
"quality_score": quality.score,
"quality_grade": quality.grade,
"confidence": round(score_pct / 100.0, 4),
}
)
base = dict(
evaluated_at=evaluation_time.isoformat(),
price=price,
gap_pct=round(gap_pct, 6),
relative_volume=round(relative_volume, 4),
criteria_passed=passed_n,
criteria_total=total_n,
success_score_pct=round(score_pct, 2),
criteria_names_passed=passed_names,
criteria_names_failed=failed_names,
setups=setups,
)
if score_pct < ready_score_pct or not setups:
reason = (
f"score {score_pct:.0f}% < {ready_score_pct:.0f}%"
if score_pct < ready_score_pct
else "no valid setup structure"
)
if failed_names:
reason += f" (failed: {', '.join(failed_names)})"
return SetupEvaluationResult(status="blocked", reason=reason, **base)
cutoff = check_entry_cutoff(
trigger_time=time(evaluation_time.hour, evaluation_time.minute),
cutoff_config=entry_cutoff or EntryCutoffConfig(),
)
if not cutoff.passed:
return SetupEvaluationResult(
status="late", reason=cutoff.reason or "past entry cutoff", **base
)
return SetupEvaluationResult(status="ready", reason=None, **base)
def _blocked(reason: str, evaluation_time: datetime) -> SetupEvaluationResult:
return SetupEvaluationResult(
status="blocked",
reason=reason,
evaluated_at=evaluation_time.isoformat(),
price=0.0,
gap_pct=0.0,
relative_volume=0.0,
criteria_passed=0,
criteria_total=0,
success_score_pct=0.0,
)
+33
View File
@@ -0,0 +1,33 @@
"""Persist setup evaluations into the research DB (setup_events table)."""
from __future__ import annotations
import uuid
from datetime import datetime
def store_setup_event(con, symbol: str, session_date, result: dict) -> str:
"""Insert one evaluation result into setup_events. Returns setup_id."""
setup_id = str(uuid.uuid4())
setups = result.get("setups") or []
best = setups[0] if setups else {}
con.execute(
"""
INSERT OR REPLACE INTO setup_events
(setup_id, symbol, setup_time, session_date, setup_name,
entry_reference_price, invalidation_price, gap_pct, relative_volume)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
setup_id,
symbol,
datetime.now(),
session_date,
best.get("setup_type") or "first_pullback",
best.get("entry_price"),
best.get("stop_loss_price"),
result.get("gap_pct"),
result.get("relative_volume"),
],
)
return setup_id
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more