feat: implement dual-pipeline signal engine service
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
New service at services/signal_engine/ implementing concurrent heuristic (deterministic scoring) and probabilistic (Bayesian inference) pipelines that evaluate technical signals across 6 timeframes (M30-M) and produce independent BUY/WATCH/SKIP verdicts per ticker per evaluation tick. Components: - Input Normalizer: multi-source data assembly with sentinel fallbacks - Signal Library: Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave - Multi-Timeframe Confluence Engine: weighted scoring with D/W/M anchors - Hard Filter Engine: macro_bias, valuation, earnings proximity gating - Heuristic Pipeline: S_total scoring with confidence-gated verdicts - Probabilistic Pipeline: Bayesian log-odds with regime priors, entropy gating, EV_R calculation, and signal correlation penalty - Exit Engine: stop-loss, targets, trailing ATR-based stops - Delta Analyzer: pipeline agreement tracking with rolling Redis metrics - Output Formatter: SignalOutput contract + Recommendation schema mapping - Worker orchestrator: concurrent pipelines with failure isolation - Main entry point: queue polling with fail-safe config loading Infrastructure: - Migration 039: signal_engine_outputs table with 3 indexes - Helm chart: signalEngine service entry (processing tier) - Redis key: QUEUE_SIGNAL_ENGINE constant Tests: 390 tests (unit + property-based) covering all components Config: dual_pipeline_enabled=false by default (safe rollout)
This commit is contained in:
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Minimal MCP server for OpenAI chat completions.
|
||||||
|
* Accepts ANY model string (gpt-5.2, gpt-5.4, etc.) — no hardcoded enum.
|
||||||
|
* Communicates over stdio using JSON-RPC (MCP protocol).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createInterface } from "readline";
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
|
if (!OPENAI_API_KEY) {
|
||||||
|
process.stderr.write("ERROR: OPENAI_API_KEY environment variable is required\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_INFO = {
|
||||||
|
name: "openai-chat",
|
||||||
|
version: "1.0.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
name: "openai_chat",
|
||||||
|
description:
|
||||||
|
"Send messages to OpenAI chat completions API. Supports all OpenAI models including GPT-5.x series.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
model: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"OpenAI model name (e.g. gpt-5.2, gpt-5.4, gpt-4o, etc.)",
|
||||||
|
default: "gpt-5.2",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
type: "array",
|
||||||
|
description: "Array of chat messages",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
role: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["system", "user", "assistant"],
|
||||||
|
},
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["role", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["messages"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function callOpenAI(model, messages) {
|
||||||
|
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model, messages }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const errText = await resp.text();
|
||||||
|
throw new Error(`OpenAI API error ${resp.status}: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.choices?.[0]?.message?.content ?? "(no response)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRpcResponse(id, result) {
|
||||||
|
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRpcError(id, code, message) {
|
||||||
|
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequest(req) {
|
||||||
|
const { id, method, params } = req;
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case "initialize":
|
||||||
|
return jsonRpcResponse(id, {
|
||||||
|
protocolVersion: "2024-11-05",
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: SERVER_INFO,
|
||||||
|
});
|
||||||
|
|
||||||
|
case "notifications/initialized":
|
||||||
|
return null; // no response needed for notifications
|
||||||
|
|
||||||
|
case "tools/list":
|
||||||
|
return jsonRpcResponse(id, { tools: TOOLS });
|
||||||
|
|
||||||
|
case "tools/call": {
|
||||||
|
const toolName = params?.name;
|
||||||
|
if (toolName !== "openai_chat") {
|
||||||
|
return jsonRpcError(id, -32602, `Unknown tool: ${toolName}`);
|
||||||
|
}
|
||||||
|
const args = params?.arguments ?? {};
|
||||||
|
const model = args.model || "gpt-5.2";
|
||||||
|
const messages = args.messages || [];
|
||||||
|
|
||||||
|
if (!messages.length) {
|
||||||
|
return jsonRpcError(id, -32602, "messages array is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await callOpenAI(model, messages);
|
||||||
|
return jsonRpcResponse(id, {
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return jsonRpcResponse(id, {
|
||||||
|
content: [{ type: "text", text: `Error: ${err.message}` }],
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ping":
|
||||||
|
return jsonRpcResponse(id, {});
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (method?.startsWith("notifications/")) return null;
|
||||||
|
return jsonRpcError(id, -32601, `Method not found: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdio transport
|
||||||
|
const rl = createInterface({ input: process.stdin });
|
||||||
|
|
||||||
|
rl.on("line", async (line) => {
|
||||||
|
try {
|
||||||
|
const req = JSON.parse(line);
|
||||||
|
const resp = await handleRequest(req);
|
||||||
|
if (resp) {
|
||||||
|
process.stdout.write(resp + "\n");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`Parse error: ${err.message}\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "d76705a8-fb91-4fce-b59e-c4b3b0dbbd83", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
# Design Document — Dual-Pipeline Signal Engine
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The dual-pipeline signal engine is a new service at `services/signal_engine/` that runs as an independent Kubernetes deployment alongside the existing aggregation → recommendation pipeline. It implements a concurrent dual-pipeline architecture where both a heuristic (deterministic scoring) and probabilistic (Bayesian inference) pipeline evaluate the same normalized inputs per ticker per evaluation tick, producing independent BUY/WATCH/SKIP verdicts. A delta analyzer compares the two verdicts, and an output formatter assembles a structured `SignalOutput` contract published to the existing `trading_decisions` Redis queue.
|
||||||
|
|
||||||
|
The engine introduces several new components — Input Normalizer, Signal Library (Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave), Multi-Timeframe Engine, Hard Filter Engine, Exit Engine, Delta Analyzer, and Output Formatter — while reusing existing infrastructure: `compute_signal_weight`, `compute_bayesian_posterior`, `classify_regime`, `WeightedSignal`, `BayesianPosterior`, and `RegimeClassification` from `services/aggregation/`.
|
||||||
|
|
||||||
|
The service is toggled via `dual_pipeline_enabled` in the `risk_configs` table (default: false, fail-safe). When disabled, the existing pipeline operates unchanged. When enabled, the signal engine runs alongside the existing pipeline with support for shadow mode (dual-pipeline output persisted but not forwarded to trading).
|
||||||
|
|
||||||
|
### Design Rationale
|
||||||
|
|
||||||
|
- **Separate service, not inline extension**: The signal engine has a fundamentally different evaluation cadence (multi-timeframe technical signals) and data flow (OHLCV bars, not document intelligence). Embedding it in the aggregation worker would couple two distinct concerns.
|
||||||
|
- **Reuse existing math**: The Bayesian posterior, regime classification, and signal weighting functions are battle-tested. The probabilistic pipeline wraps them with regime-based priors and likelihood ratio accumulation rather than reimplementing.
|
||||||
|
- **Concurrent pipelines via asyncio.gather**: Both pipelines share the same `NormalizedInput` reference and run concurrently. If one fails, the other completes normally with the failed pipeline producing a SKIP verdict.
|
||||||
|
- **Signal clustering for correlation penalty**: The Bayesian pipeline groups signals into four clusters (momentum, structure, volatility, fundamentals) and applies exponential decay within each cluster to prevent likelihood ratio stacking inflation from correlated signals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### High-Level Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Evaluation Tick<br/>Redis queue: signal_engine] --> B[Input Normalizer]
|
||||||
|
B --> C[Hard Filter Engine]
|
||||||
|
C -->|filtered out| D[SKIP verdict for both pipelines]
|
||||||
|
C -->|passed| E[Signal Library]
|
||||||
|
E --> F[Multi-Timeframe Engine]
|
||||||
|
F --> G{asyncio.gather}
|
||||||
|
G --> H[Heuristic Pipeline]
|
||||||
|
G --> I[Probabilistic Pipeline]
|
||||||
|
H --> J[Delta Analyzer]
|
||||||
|
I --> J
|
||||||
|
J --> K[Output Formatter]
|
||||||
|
K --> L[SignalOutput]
|
||||||
|
L --> M[Redis: trading_decisions queue]
|
||||||
|
L --> N[PostgreSQL: signal_engine_outputs]
|
||||||
|
|
||||||
|
subgraph Exit Path
|
||||||
|
B --> O[Exit Engine]
|
||||||
|
O --> K
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trigger Mechanism
|
||||||
|
|
||||||
|
The signal engine polls a new Redis queue `stonks:queue:signal_engine`. Evaluation ticks are enqueued by the scheduler service after aggregation completes for a ticker. The queue message contains `{"ticker": "AAPL", "triggered_at": "2024-01-15T10:00:00Z"}`.
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
| Component | Integration | Direction |
|
||||||
|
|---|---|---|
|
||||||
|
| Scheduler | Enqueues ticks to `signal_engine` queue | Scheduler → Signal Engine |
|
||||||
|
| Market data tables | OHLCV bars, closing prices, returns | Signal Engine reads |
|
||||||
|
| `macro_impact_records` | Macro bias computation | Signal Engine reads |
|
||||||
|
| `trend_windows` | Fundamental/valuation context | Signal Engine reads |
|
||||||
|
| `risk_configs` | Feature flags, thresholds | Signal Engine reads |
|
||||||
|
| `classify_regime()` | Regime classification for priors | Signal Engine calls |
|
||||||
|
| `compute_signal_weight()` | Heuristic signal weighting | Signal Engine calls |
|
||||||
|
| `compute_bayesian_posterior()` | Bayesian accumulation | Signal Engine calls |
|
||||||
|
| Redis `trading_decisions` | SignalOutput publication | Signal Engine → Trading Engine |
|
||||||
|
| `signal_engine_outputs` table | Persistence for audit | Signal Engine writes |
|
||||||
|
| Redis rolling agreement | Delta analyzer metrics | Signal Engine writes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
services/signal_engine/
|
||||||
|
├── __init__.py
|
||||||
|
├── main.py # Entry point: asyncio event loop, queue polling
|
||||||
|
├── worker.py # Top-level orchestrator per evaluation tick
|
||||||
|
├── config.py # SignalEngineConfig, loaded from risk_configs + env
|
||||||
|
├── models.py # All Pydantic models (NormalizedInput, SignalResult, etc.)
|
||||||
|
├── normalizer.py # Input Normalizer — fetches and assembles NormalizedInput
|
||||||
|
├── signals/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── base.py # SignalEvaluator protocol, SignalResult model
|
||||||
|
│ ├── fibonacci.py # Fibonacci retracement evaluator
|
||||||
|
│ ├── ma_stack.py # Moving average stack evaluator
|
||||||
|
│ ├── rsi.py # RSI evaluator
|
||||||
|
│ ├── cup_handle.py # Cup & Handle pattern detector
|
||||||
|
│ └── elliott_wave.py # Elliott Wave detector
|
||||||
|
├── confluence.py # Multi-Timeframe Confluence Engine
|
||||||
|
├── hard_filter.py # Hard Filter Engine
|
||||||
|
├── heuristic.py # Heuristic Pipeline (Pipeline A)
|
||||||
|
├── probabilistic.py # Probabilistic Pipeline (Pipeline B)
|
||||||
|
├── correlation.py # Signal cluster classification + correlation penalty
|
||||||
|
├── exit_engine.py # Exit Engine — position-level exit management
|
||||||
|
├── delta.py # Delta Analyzer
|
||||||
|
├── formatter.py # Output Formatter
|
||||||
|
└── persistence.py # Database persistence for signal_engine_outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Function Signatures
|
||||||
|
|
||||||
|
#### `main.py` — Entry Point
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def main() -> None:
|
||||||
|
"""Start the signal engine worker loop.
|
||||||
|
|
||||||
|
Connects to PostgreSQL and Redis, loads config from risk_configs,
|
||||||
|
and polls the signal_engine queue indefinitely.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `worker.py` — Orchestrator
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def evaluate_tick(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
redis: redis.asyncio.Redis,
|
||||||
|
ticker: str,
|
||||||
|
config: SignalEngineConfig,
|
||||||
|
) -> SignalOutput | None:
|
||||||
|
"""Run a full evaluation tick for a single ticker.
|
||||||
|
|
||||||
|
1. Normalize inputs
|
||||||
|
2. Evaluate exit conditions for open positions
|
||||||
|
3. Run hard filters
|
||||||
|
4. Evaluate signals across timeframes
|
||||||
|
5. Run both pipelines concurrently
|
||||||
|
6. Compute delta analysis
|
||||||
|
7. Format and publish output
|
||||||
|
|
||||||
|
Returns None if the ticker is hard-filtered or both pipelines fail.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `normalizer.py` — Input Normalizer
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def normalize_input(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
ticker: str,
|
||||||
|
config: SignalEngineConfig,
|
||||||
|
) -> NormalizedInput:
|
||||||
|
"""Fetch and assemble all data needed for a single evaluation tick.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- OHLCV bars from market_data_bars (M30, H1, H4, D, W, M)
|
||||||
|
- Fundamental metrics from trend_windows + companies
|
||||||
|
- Macro context from macro_impact_records + global_events
|
||||||
|
- Open position state from the trading engine's portfolio
|
||||||
|
|
||||||
|
Missing data sources produce sentinel values (None/empty list)
|
||||||
|
with a logged warning.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `signals/base.py` — Signal Evaluator Protocol
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
class SignalEvaluator(Protocol):
|
||||||
|
"""Protocol for all signal evaluators in the Signal Library."""
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
timeframe: str,
|
||||||
|
) -> SignalResult | None:
|
||||||
|
"""Evaluate a signal on a single timeframe's bar data.
|
||||||
|
|
||||||
|
Returns None when insufficient data is available.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `confluence.py` — Multi-Timeframe Engine
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compute_confluence(
|
||||||
|
signal_results: dict[str, dict[str, SignalResult]],
|
||||||
|
weights: dict[str, float],
|
||||||
|
) -> list[ConfluenceSignal]:
|
||||||
|
"""Compute weighted confluence scores across timeframes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal_results: {signal_type: {timeframe: SignalResult}}
|
||||||
|
weights: {timeframe: weight} e.g. {"M30": 0.03, "D": 0.30, ...}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ConfluenceSignal objects that pass the minimum
|
||||||
|
confluence threshold (≥2 timeframes, ≥1 of D/W/M).
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `hard_filter.py` — Hard Filter Engine
|
||||||
|
|
||||||
|
```python
|
||||||
|
def evaluate_hard_filters(
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
config: HardFilterConfig,
|
||||||
|
) -> HardFilterResult:
|
||||||
|
"""Evaluate pre-pipeline hard filters.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- macro_bias == -1.0 → SKIP
|
||||||
|
- valuation_score < threshold → SKIP
|
||||||
|
- earnings_proximity_days <= threshold → SKIP
|
||||||
|
|
||||||
|
Returns HardFilterResult with filtered=True/False and all triggered reasons.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `heuristic.py` — Heuristic Pipeline
|
||||||
|
|
||||||
|
```python
|
||||||
|
def run_heuristic_pipeline(
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
confluence_signals: list[ConfluenceSignal],
|
||||||
|
config: HeuristicConfig,
|
||||||
|
) -> HeuristicResult:
|
||||||
|
"""Run the deterministic heuristic pipeline.
|
||||||
|
|
||||||
|
Computes S_total = S_company + S_macro + S_competitive using
|
||||||
|
existing compute_signal_weight() and weighted sentiment averaging.
|
||||||
|
Produces BUY/WATCH/SKIP verdict based on confidence and score thresholds.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `probabilistic.py` — Probabilistic Pipeline
|
||||||
|
|
||||||
|
```python
|
||||||
|
def run_probabilistic_pipeline(
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
confluence_signals: list[ConfluenceSignal],
|
||||||
|
regime: RegimeClassification,
|
||||||
|
config: ProbabilisticConfig,
|
||||||
|
) -> ProbabilisticResult:
|
||||||
|
"""Run the Bayesian probabilistic pipeline.
|
||||||
|
|
||||||
|
1. Initialize regime-based prior (bull=0.58, range=0.50, bear=0.42)
|
||||||
|
2. Compute likelihood ratios per signal with correlation penalty
|
||||||
|
3. Accumulate via log-odds: logit(P_post) = logit(P_prior) + Σ log(LR_i)
|
||||||
|
4. Apply entropy gating
|
||||||
|
5. Compute EV_R = P_up · E[win_R] - (1 - P_up) · 1.0
|
||||||
|
6. Produce BUY/WATCH/SKIP verdict
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `correlation.py` — Signal Correlation Penalty
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SignalCluster(str, Enum):
|
||||||
|
MOMENTUM = "momentum" # MA stack, RSI
|
||||||
|
STRUCTURE = "structure" # Fibonacci, Elliott Wave
|
||||||
|
VOLATILITY = "volatility" # ATR-based, Bollinger-derived
|
||||||
|
FUNDAMENTALS = "fundamentals" # valuation, earnings, macro
|
||||||
|
|
||||||
|
def classify_signal(signal_type: str) -> SignalCluster:
|
||||||
|
"""Map a signal type to its correlation cluster."""
|
||||||
|
|
||||||
|
def apply_correlation_penalty(
|
||||||
|
likelihood_ratios: list[LikelihoodRatio],
|
||||||
|
) -> list[LikelihoodRatio]:
|
||||||
|
"""Apply within-cluster decay penalty to correlated signals.
|
||||||
|
|
||||||
|
Within each cluster, signals are ranked by LR magnitude.
|
||||||
|
The strongest contributes at full weight; subsequent signals
|
||||||
|
contribute at 0.5^(n-1) decay.
|
||||||
|
|
||||||
|
Cross-cluster signals are independent (no penalty).
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `exit_engine.py` — Exit Engine
|
||||||
|
|
||||||
|
```python
|
||||||
|
def evaluate_exits(
|
||||||
|
positions: list[OpenPositionState],
|
||||||
|
current_prices: dict[str, float],
|
||||||
|
config: ExitConfig,
|
||||||
|
) -> list[ExitSignal]:
|
||||||
|
"""Evaluate exit conditions for all open positions.
|
||||||
|
|
||||||
|
Checks: stop_loss hit, target_1 hit (EXIT_HALF), target_2 hit (EXIT_FULL),
|
||||||
|
trailing stop hit (EXIT_FULL for remaining).
|
||||||
|
|
||||||
|
Trailing stop activates after EXIT_HALF and ratchets upward only.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `delta.py` — Delta Analyzer
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def analyze_delta(
|
||||||
|
heuristic: HeuristicResult,
|
||||||
|
probabilistic: ProbabilisticResult,
|
||||||
|
redis: redis.asyncio.Redis,
|
||||||
|
ticker: str,
|
||||||
|
) -> DeltaResult:
|
||||||
|
"""Compare pipeline verdicts and track agreement metrics.
|
||||||
|
|
||||||
|
Computes agreement flag, confidence delta, disagreement reasons.
|
||||||
|
Updates rolling 100-evaluation agreement rate in Redis.
|
||||||
|
Logs warning when agreement rate drops below 0.50.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `formatter.py` — Output Formatter
|
||||||
|
|
||||||
|
```python
|
||||||
|
def format_output(
|
||||||
|
ticker: str,
|
||||||
|
price: float,
|
||||||
|
heuristic: HeuristicResult,
|
||||||
|
probabilistic: ProbabilisticResult,
|
||||||
|
delta: DeltaResult,
|
||||||
|
exit_signals: list[ExitSignal],
|
||||||
|
config: SignalEngineConfig,
|
||||||
|
) -> SignalOutput:
|
||||||
|
"""Assemble the structured SignalOutput contract.
|
||||||
|
|
||||||
|
Populates trade_plan based on verdict combination:
|
||||||
|
- Both BUY → dual_confirmed, full position sizing
|
||||||
|
- Probabilistic-only BUY → probabilistic_only, 50% position sizing
|
||||||
|
- Heuristic-only BUY → standard position sizing
|
||||||
|
- No BUY → no trade_plan (WATCH/SKIP persisted for analysis)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def signal_output_to_recommendation(output: SignalOutput) -> Recommendation:
|
||||||
|
"""Map a SignalOutput to the existing Recommendation schema.
|
||||||
|
|
||||||
|
Enables the trading engine to consume dual-pipeline outputs
|
||||||
|
without modification to its core evaluate_recommendation logic.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `persistence.py` — Database Persistence
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def persist_signal_output(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
output: SignalOutput,
|
||||||
|
) -> None:
|
||||||
|
"""Persist a SignalOutput to the signal_engine_outputs table.
|
||||||
|
|
||||||
|
Logs and continues on database errors (persistence failure
|
||||||
|
does not block signal emission to the trading queue).
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
All new data models are Pydantic `BaseModel` subclasses defined in `services/signal_engine/models.py`. Existing models (`WeightedSignal`, `BayesianPosterior`, `RegimeClassification`, `TrendSummary`, `Recommendation`, `PositionSizing`) are imported from `services/aggregation/` and `services/shared/schemas.py`.
|
||||||
|
|
||||||
|
### OHLCVBar
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OHLCVBar(BaseModel):
|
||||||
|
"""Single OHLCV bar for a timeframe."""
|
||||||
|
timestamp: datetime
|
||||||
|
open: float
|
||||||
|
high: float
|
||||||
|
low: float
|
||||||
|
close: float
|
||||||
|
volume: float
|
||||||
|
```
|
||||||
|
|
||||||
|
### NormalizedInput
|
||||||
|
|
||||||
|
```python
|
||||||
|
class NormalizedInput(BaseModel):
|
||||||
|
"""Unified input structure consumed by both pipelines."""
|
||||||
|
ticker: str
|
||||||
|
evaluated_at: datetime
|
||||||
|
|
||||||
|
# Multi-timeframe OHLCV bars
|
||||||
|
bars: dict[str, list[OHLCVBar]] # {"M30": [...], "H1": [...], ...}
|
||||||
|
|
||||||
|
# Fundamental metrics
|
||||||
|
valuation_score: float | None = None # [0.0, 1.0]
|
||||||
|
earnings_proximity_days: int | None = None
|
||||||
|
|
||||||
|
# Macro context
|
||||||
|
macro_bias: float = 0.0 # [-1.0, 1.0]
|
||||||
|
|
||||||
|
# Open position state (for exit engine)
|
||||||
|
open_positions: list[OpenPositionState] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Market data for regime classification
|
||||||
|
closing_prices: list[float] = Field(default_factory=list)
|
||||||
|
returns: list[float] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Current price (latest close from shortest available timeframe)
|
||||||
|
current_price: float | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenPositionState
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OpenPositionState(BaseModel):
|
||||||
|
"""Snapshot of an open position for exit evaluation."""
|
||||||
|
position_id: str
|
||||||
|
ticker: str
|
||||||
|
entry_price: float
|
||||||
|
current_price: float
|
||||||
|
stop_loss: float
|
||||||
|
target_1: float
|
||||||
|
target_2: float
|
||||||
|
trailing_stop: float | None = None
|
||||||
|
partial_exit_done: bool = False
|
||||||
|
atr: float | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### SignalResult
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SignalDirection(str, Enum):
|
||||||
|
BULLISH = "bullish"
|
||||||
|
BEARISH = "bearish"
|
||||||
|
NEUTRAL = "neutral"
|
||||||
|
|
||||||
|
class SignalResult(BaseModel):
|
||||||
|
"""Output from a single signal evaluator on a single timeframe."""
|
||||||
|
signal_type: str # e.g. "fibonacci", "ma_stack", "rsi"
|
||||||
|
timeframe: str # e.g. "D", "H4"
|
||||||
|
strength: float = Field(ge=0.0, le=1.0)
|
||||||
|
direction: SignalDirection
|
||||||
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
metadata: dict = Field(default_factory=dict) # signal-specific details
|
||||||
|
```
|
||||||
|
|
||||||
|
### ConfluenceSignal
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ConfluenceSignal(BaseModel):
|
||||||
|
"""A signal that passed multi-timeframe confluence filtering."""
|
||||||
|
signal_type: str
|
||||||
|
direction: SignalDirection
|
||||||
|
confluence_score: float # weighted sum across timeframes
|
||||||
|
active_timeframes: list[str] # which timeframes triggered
|
||||||
|
per_timeframe: dict[str, float] # {timeframe: strength}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verdict
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Verdict(str, Enum):
|
||||||
|
BUY = "BUY"
|
||||||
|
WATCH = "WATCH"
|
||||||
|
SKIP = "SKIP"
|
||||||
|
```
|
||||||
|
|
||||||
|
### HeuristicResult
|
||||||
|
|
||||||
|
```python
|
||||||
|
class HeuristicResult(BaseModel):
|
||||||
|
"""Output from the heuristic (deterministic) pipeline."""
|
||||||
|
verdict: Verdict
|
||||||
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
s_total: float
|
||||||
|
s_company: float
|
||||||
|
s_macro: float
|
||||||
|
s_competitive: float
|
||||||
|
signal_weights: list[dict] = Field(default_factory=list)
|
||||||
|
reasoning: list[str] = Field(default_factory=list)
|
||||||
|
```
|
||||||
|
|
||||||
|
### LikelihoodRatio
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LikelihoodRatio(BaseModel):
|
||||||
|
"""A single signal's likelihood ratio for Bayesian updating."""
|
||||||
|
signal_type: str
|
||||||
|
cluster: str # SignalCluster value
|
||||||
|
lr: float # P(sig|up) / P(sig|down)
|
||||||
|
log_lr: float # log(lr)
|
||||||
|
penalized_log_lr: float # after correlation penalty
|
||||||
|
hit_rate: float
|
||||||
|
strength: float
|
||||||
|
```
|
||||||
|
|
||||||
|
### ProbabilisticResult
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ProbabilisticResult(BaseModel):
|
||||||
|
"""Output from the probabilistic (Bayesian) pipeline."""
|
||||||
|
verdict: Verdict
|
||||||
|
p_up: float = Field(ge=0.0, le=1.0)
|
||||||
|
entropy: float = Field(ge=0.0, le=1.0)
|
||||||
|
ev_r: float
|
||||||
|
prior: float
|
||||||
|
posterior: float
|
||||||
|
likelihood_ratios: list[LikelihoodRatio] = Field(default_factory=list)
|
||||||
|
regime: str
|
||||||
|
reasoning: list[str] = Field(default_factory=list)
|
||||||
|
```
|
||||||
|
|
||||||
|
### DeltaResult
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DeltaResult(BaseModel):
|
||||||
|
"""Output from the delta analyzer comparing both pipelines."""
|
||||||
|
agreement: bool
|
||||||
|
confidence_delta: float
|
||||||
|
heuristic_verdict: str
|
||||||
|
probabilistic_verdict: str
|
||||||
|
disagreement_reasons: list[str] = Field(default_factory=list)
|
||||||
|
rolling_agreement_rate: float | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### ExitSignal
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ExitType(str, Enum):
|
||||||
|
EXIT_HALF = "EXIT_HALF"
|
||||||
|
EXIT_FULL = "EXIT_FULL"
|
||||||
|
|
||||||
|
class ExitSignal(BaseModel):
|
||||||
|
"""An exit signal for an open position."""
|
||||||
|
position_id: str
|
||||||
|
ticker: str
|
||||||
|
exit_type: ExitType
|
||||||
|
reason: str # "stop_hit", "target_1_hit", "target_2_hit", "trailing_stop_hit"
|
||||||
|
price: float
|
||||||
|
```
|
||||||
|
|
||||||
|
### TradePlan
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TradePlan(BaseModel):
|
||||||
|
"""Optional trade plan attached to a BUY signal."""
|
||||||
|
entry_price: float
|
||||||
|
stop_loss: float
|
||||||
|
target_1: float
|
||||||
|
target_2: float
|
||||||
|
position_size_pct: float = Field(ge=0.0, le=1.0)
|
||||||
|
max_loss_pct: float = Field(ge=0.0, le=1.0)
|
||||||
|
dual_confirmed: bool = False
|
||||||
|
probabilistic_only: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
### SignalOutput
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SignalOutput(BaseModel):
|
||||||
|
"""The structured output contract consumed by the trading engine and audit systems."""
|
||||||
|
output_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
ticker: str
|
||||||
|
timestamp: datetime
|
||||||
|
price: float
|
||||||
|
|
||||||
|
# Heuristic pipeline results
|
||||||
|
heuristic_verdict: str
|
||||||
|
heuristic_confidence: float
|
||||||
|
heuristic_s_total: float
|
||||||
|
|
||||||
|
# Probabilistic pipeline results
|
||||||
|
probabilistic_verdict: str
|
||||||
|
probabilistic_p_up: float
|
||||||
|
probabilistic_entropy: float
|
||||||
|
probabilistic_ev_r: float
|
||||||
|
|
||||||
|
# Delta analysis
|
||||||
|
delta_agreement: bool
|
||||||
|
delta_confidence_delta: float
|
||||||
|
delta_reasons: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Optional trade plan (populated when at least one pipeline says BUY)
|
||||||
|
trade_plan: TradePlan | None = None
|
||||||
|
|
||||||
|
# Exit signals for open positions
|
||||||
|
exit_signals: list[ExitSignal] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Full pipeline results for audit (stored as JSONB)
|
||||||
|
heuristic_detail: dict = Field(default_factory=dict)
|
||||||
|
probabilistic_detail: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Pipeline mode metadata
|
||||||
|
pipeline_mode: str = "dual_pipeline"
|
||||||
|
shadow_mode: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
### SignalEngineConfig
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class SignalEngineConfig:
|
||||||
|
"""Configuration loaded from risk_configs + environment."""
|
||||||
|
dual_pipeline_enabled: bool = False
|
||||||
|
heuristic_pipeline_enabled: bool = True
|
||||||
|
probabilistic_pipeline_enabled: bool = True
|
||||||
|
shadow_mode: bool = False
|
||||||
|
|
||||||
|
# Timeframe weights
|
||||||
|
timeframe_weights: dict[str, float] = field(default_factory=lambda: {
|
||||||
|
"M30": 0.03, "H1": 0.07, "H4": 0.15,
|
||||||
|
"D": 0.30, "W": 0.30, "M": 0.15,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Hard filter thresholds
|
||||||
|
hard_filter_valuation_min: float = 0.3
|
||||||
|
hard_filter_earnings_days: int = 5
|
||||||
|
hard_filter_macro_bias_skip: float = -1.0
|
||||||
|
|
||||||
|
# Heuristic verdict thresholds
|
||||||
|
heuristic_buy_confidence: float = 0.70
|
||||||
|
heuristic_buy_s_total: float = 1.2
|
||||||
|
heuristic_buy_valuation_min: float = 0.5
|
||||||
|
heuristic_watch_confidence: float = 0.55
|
||||||
|
|
||||||
|
# Probabilistic verdict thresholds
|
||||||
|
prob_buy_p_up: float = 0.60
|
||||||
|
prob_buy_entropy_max: float = 0.90
|
||||||
|
prob_buy_ev_r_min: float = 1.5
|
||||||
|
prob_buy_valuation_min: float = 0.5
|
||||||
|
prob_watch_p_up: float = 0.55
|
||||||
|
prob_watch_entropy_max: float = 0.95
|
||||||
|
prob_entropy_skip: float = 0.95
|
||||||
|
|
||||||
|
# Regime priors
|
||||||
|
regime_prior_bull: float = 0.58
|
||||||
|
regime_prior_range: float = 0.50
|
||||||
|
regime_prior_bear: float = 0.42
|
||||||
|
|
||||||
|
# Exit engine
|
||||||
|
trailing_stop_atr_multiplier: float = 2.0
|
||||||
|
|
||||||
|
# Polling
|
||||||
|
polling_interval_seconds: int = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### HardFilterConfig / HeuristicConfig / ProbabilisticConfig / ExitConfig
|
||||||
|
|
||||||
|
These are derived from `SignalEngineConfig` fields for cleaner function signatures — simple `@dataclass` wrappers over the relevant subset of config values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Database Migration (039)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration 039: Signal Engine Outputs
|
||||||
|
-- Creates the signal_engine_outputs table for persisting dual-pipeline evaluations.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS signal_engine_outputs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
evaluated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
price NUMERIC NOT NULL,
|
||||||
|
|
||||||
|
-- Heuristic pipeline
|
||||||
|
heuristic_verdict TEXT NOT NULL,
|
||||||
|
heuristic_confidence NUMERIC NOT NULL,
|
||||||
|
heuristic_s_total NUMERIC NOT NULL,
|
||||||
|
|
||||||
|
-- Probabilistic pipeline
|
||||||
|
probabilistic_verdict TEXT NOT NULL,
|
||||||
|
probabilistic_p_up NUMERIC NOT NULL,
|
||||||
|
probabilistic_entropy NUMERIC NOT NULL,
|
||||||
|
probabilistic_ev_r NUMERIC NOT NULL,
|
||||||
|
|
||||||
|
-- Delta analysis
|
||||||
|
delta_agreement BOOLEAN NOT NULL,
|
||||||
|
delta_confidence_delta NUMERIC NOT NULL,
|
||||||
|
delta_reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- Trade plan (null when no BUY verdict)
|
||||||
|
trade_plan JSONB,
|
||||||
|
|
||||||
|
-- Full output for audit
|
||||||
|
full_output JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Exit signals
|
||||||
|
exit_signals JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
pipeline_mode TEXT NOT NULL DEFAULT 'dual_pipeline',
|
||||||
|
shadow_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for per-ticker time-range queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signal_engine_outputs_ticker_time
|
||||||
|
ON signal_engine_outputs (ticker, evaluated_at);
|
||||||
|
|
||||||
|
-- Index for global time-range queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signal_engine_outputs_evaluated
|
||||||
|
ON signal_engine_outputs (evaluated_at);
|
||||||
|
|
||||||
|
-- Index for filtering by verdict
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signal_engine_outputs_verdicts
|
||||||
|
ON signal_engine_outputs (heuristic_verdict, probabilistic_verdict);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helm / Deployment Configuration
|
||||||
|
|
||||||
|
Add to `values.yaml` under `services:`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
signalEngine:
|
||||||
|
replicas: 1
|
||||||
|
pipeline: true
|
||||||
|
image: signal-engine
|
||||||
|
command: "python -m services.signal_engine.main"
|
||||||
|
tier: processing
|
||||||
|
secrets: [stonks-core-secrets, stonks-market-secrets]
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 100m, memory: 128Mi }
|
||||||
|
limits: { cpu: 500m, memory: 256Mi }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `redis_keys.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
QUEUE_SIGNAL_ENGINE = "signal_engine"
|
||||||
|
```
|
||||||
|
|
||||||
|
The service uses the existing `stonks-config` ConfigMap and `stonks-core-secrets` for database/Redis credentials. No new ingress or network policy is needed — the signal engine is a queue-polling worker with no HTTP interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Requirements Document — Dual-Pipeline Signal Engine
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Stonks Oracle platform currently operates a single aggregation pipeline that can run in either heuristic or probabilistic mode (toggled via `probabilistic_scoring_enabled`). This feature replaces the single-pipeline toggle with a dual-pipeline architecture where both pipelines run concurrently per evaluation tick, produce independent verdicts (BUY/WATCH/SKIP), and emit a structured output contract for downstream consumers (trading engine, delta analysis, dashboards).
|
||||||
|
|
||||||
|
The dual-pipeline engine introduces:
|
||||||
|
- **Pipeline A (Heuristic)**: Deterministic scoring using the existing `S_total = S_company + S_macro + S_competitive` formula with signal weighting, producing a confidence-gated verdict.
|
||||||
|
- **Pipeline B (Probabilistic)**: Bayesian inference using the existing `bayesian.py` infrastructure with regime-based priors, likelihood ratios, entropy gating, and expected value calculation.
|
||||||
|
- **Hard Filter Engine**: Pre-pipeline filters that short-circuit both pipelines before evaluation.
|
||||||
|
- **Multi-Timeframe Engine**: Signal evaluation across M30, H1, H4, D, W, M timeframes with weighted confluence scoring.
|
||||||
|
- **Exit Engine**: Position-level exit management (stop hit, targets, trailing ATR-based).
|
||||||
|
- **Delta Analyzer**: Compares heuristic vs probabilistic verdicts to generate training signals for future model tuning.
|
||||||
|
- **Output Formatter**: Structured `SignalOutput` contract consumed by the trading engine and delta analysis.
|
||||||
|
|
||||||
|
The design must address the signal independence assumption in the Bayesian pipeline — correlated signals (MA+RSI, Fib+Elliott) require correlation penalty or signal clustering into categories (momentum, structure, volatility, fundamentals) to prevent likelihood ratio stacking inflation.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Signal_Engine**: The top-level orchestrator in `services/signal_engine/` that coordinates input normalization, hard filters, both pipelines, delta analysis, and output formatting per evaluation tick.
|
||||||
|
- **Heuristic_Pipeline**: Pipeline A — deterministic scoring that computes `S_total = S_company + S_macro + S_competitive` with signal weighting and produces a confidence-gated BUY/WATCH/SKIP verdict.
|
||||||
|
- **Probabilistic_Pipeline**: Pipeline B — Bayesian inference pipeline that computes posterior probability via log-likelihood accumulation with regime-based priors, entropy gating, and expected value calculation.
|
||||||
|
- **Input_Normalizer**: The component that ingests multi-timeframe OHLCV data, fundamentals, macro context, and open positions into a unified `NormalizedInput` structure consumed by both pipelines.
|
||||||
|
- **Signal_Library**: The collection of technical signal evaluators (Fibonacci retracement, MA stack, RSI, Cup & Handle, Elliott Wave) that produce scored signals per timeframe.
|
||||||
|
- **Multi_Timeframe_Engine**: The component that evaluates signals across six timeframes (M30, H1, H4, D, W, M) and computes weighted confluence scores.
|
||||||
|
- **Hard_Filter_Engine**: The pre-pipeline filter stage that evaluates macro bias, valuation score, and earnings proximity to short-circuit evaluation before either pipeline runs.
|
||||||
|
- **Exit_Engine**: The position management component that evaluates stop hits, take-profit targets, and trailing ATR-based stops for open positions.
|
||||||
|
- **Delta_Analyzer**: The component that compares heuristic and probabilistic verdicts, tracks agreement rates, measures confidence deltas, and records disagreement reasons as training signals.
|
||||||
|
- **Output_Formatter**: The component that assembles the structured `SignalOutput` contract from both pipeline results, delta analysis, and optional trade plan.
|
||||||
|
- **SignalOutput**: The structured output contract containing ticker, timestamp, price, heuristic verdict/confidence/S_total, probabilistic verdict/P_up/entropy/EV_R, delta analysis, and optional trade plan.
|
||||||
|
- **Verdict**: A pipeline decision of BUY, WATCH, or SKIP with associated confidence and reasoning.
|
||||||
|
- **Confluence**: The condition where a signal triggers across multiple timeframes; requires activation on at least 2 timeframes including at least one of D, W, or M.
|
||||||
|
- **Entropy_Gate**: Shannon entropy threshold used in the probabilistic pipeline to detect high-uncertainty states and force SKIP verdicts.
|
||||||
|
- **EV_R**: Expected value per unit of risk, computed as `P_up · E[win_R] - (1 - P_up) · 1.0`, used as a quality gate in the probabilistic pipeline.
|
||||||
|
- **Signal_Cluster**: A grouping of correlated signals (momentum, structure, volatility, fundamentals) used to prevent likelihood ratio stacking inflation in the Bayesian pipeline.
|
||||||
|
- **Likelihood_Ratio**: The ratio `P(signal|up) / P(signal|down)` used in Bayesian updating, where `P(sig|up) = h·s + (1-h)·(1-s)·0.5`.
|
||||||
|
- **Regime_Prior**: The initial probability assigned based on market regime classification: bull=0.58, range=0.50, bear=0.42.
|
||||||
|
- **OHLCV**: Open, High, Low, Close, Volume — standard market data bar format.
|
||||||
|
- **ATR**: Average True Range — a volatility measure used for trailing stop calculations.
|
||||||
|
- **Fibonacci_Retracement**: A technical analysis tool computing price levels as `L(r) = SH - r·(SH - SL)` where SH is swing high, SL is swing low, and r is a retracement ratio (0.236, 0.382, 0.5, 0.618, 0.786).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Input Normalization
|
||||||
|
|
||||||
|
**User Story:** As a signal engine operator, I want all market data, fundamentals, macro context, and open positions normalized into a single input structure, so that both pipelines consume identical inputs per evaluation tick.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN an evaluation tick is triggered for a ticker, THE Input_Normalizer SHALL construct a `NormalizedInput` containing multi-timeframe OHLCV bars (M30, H1, H4, D, W, M), fundamental metrics (valuation_score, earnings_proximity_days), macro context (macro_bias as float in [-1.0, 1.0]), and open position state (entry_price, current_price, stop_loss, targets).
|
||||||
|
2. THE Input_Normalizer SHALL source OHLCV data from the existing market data tables, fundamental metrics from the existing company and trend data, and macro context from the existing `macro_impact_records` and `global_events` tables.
|
||||||
|
3. IF any required data source is unavailable or returns an error, THEN THE Input_Normalizer SHALL populate the corresponding field with a sentinel value (`None` for optional fields, empty list for OHLCV bars) and log a warning identifying the missing source.
|
||||||
|
4. THE Input_Normalizer SHALL validate that all OHLCV bars have monotonically increasing timestamps within each timeframe series.
|
||||||
|
5. THE Input_Normalizer SHALL produce identical `NormalizedInput` instances for both pipelines within the same evaluation tick (shared reference, no independent fetches).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 2: Signal Library — Technical Signal Evaluation
|
||||||
|
|
||||||
|
**User Story:** As a quantitative analyst, I want a library of technical signal evaluators that produce scored signals per timeframe, so that both pipelines can consume standardized signal assessments.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Signal_Library SHALL implement Fibonacci retracement signal evaluation using the formula `L(r) = SH - r·(SH - SL)` for retracement ratios [0.236, 0.382, 0.5, 0.618, 0.786], where SH is the swing high and SL is the swing low within the evaluation window.
|
||||||
|
2. THE Signal_Library SHALL implement moving average stack evaluation that detects bullish alignment (MA_10 > MA_20 > MA_50 > MA_200) and bearish alignment (MA_10 < MA_20 < MA_50 < MA_200), producing a signal strength proportional to the degree of alignment.
|
||||||
|
3. THE Signal_Library SHALL implement RSI evaluation using the standard 14-period RSI formula, producing overbought signals (RSI > 70) and oversold signals (RSI < 30) with strength scaled by distance from the threshold.
|
||||||
|
4. THE Signal_Library SHALL implement Cup & Handle pattern detection that identifies the cup formation (U-shaped price recovery) and handle (small consolidation), producing a signal with confidence proportional to pattern completeness.
|
||||||
|
5. THE Signal_Library SHALL implement Elliott Wave detection that identifies impulse waves (5-wave structure) and corrective waves (3-wave structure), producing a signal with the current wave position and projected direction.
|
||||||
|
6. WHEN a signal evaluator receives insufficient data for its calculation (fewer bars than the required lookback period), THE Signal_Library SHALL return a null signal with a reason code indicating insufficient data rather than producing a partial evaluation.
|
||||||
|
7. FOR ALL signal evaluators, THE Signal_Library SHALL produce output conforming to a common `SignalResult` structure containing: signal_type, timeframe, strength (float in [0.0, 1.0]), direction (bullish/bearish/neutral), confidence (float in [0.0, 1.0]), and metadata specific to the signal type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 3: Multi-Timeframe Confluence Engine
|
||||||
|
|
||||||
|
**User Story:** As a quantitative analyst, I want signals evaluated across multiple timeframes with weighted confluence scoring, so that the engine prioritizes signals confirmed across longer timeframes.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Multi_Timeframe_Engine SHALL evaluate each signal type across six timeframes with the following weights: M30=0.03, H1=0.07, H4=0.15, D=0.30, W=0.30, M=0.15.
|
||||||
|
2. THE Multi_Timeframe_Engine SHALL compute a weighted confluence score as `C_confluence = Σ(w_tf · s_tf)` where `w_tf` is the timeframe weight and `s_tf` is the signal strength on that timeframe (0.0 if the signal did not trigger).
|
||||||
|
3. WHEN a signal triggers on fewer than 2 timeframes, THE Multi_Timeframe_Engine SHALL discard the signal from further pipeline processing (minimum confluence threshold).
|
||||||
|
4. WHEN a signal triggers on 2 or more timeframes but none of D, W, or M are included, THE Multi_Timeframe_Engine SHALL discard the signal from further pipeline processing (higher-timeframe anchor requirement).
|
||||||
|
5. THE Multi_Timeframe_Engine SHALL pass the confluence-filtered signals and their weighted scores to both the Heuristic_Pipeline and Probabilistic_Pipeline.
|
||||||
|
6. FOR ALL signal sets where a signal triggers on more timeframes with higher weights, THE Multi_Timeframe_Engine SHALL produce a higher confluence score (monotonicity with respect to timeframe activation count and weight).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 4: Hard Filter Engine — Pre-Pipeline Gating
|
||||||
|
|
||||||
|
**User Story:** As a risk manager, I want hard filters that short-circuit both pipelines before evaluation, so that clearly unfavorable conditions produce immediate SKIP verdicts without wasting computation.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the macro_bias value from the NormalizedInput equals -1.0, THE Hard_Filter_Engine SHALL produce an immediate SKIP verdict for both pipelines with reason "macro_bias_negative".
|
||||||
|
2. WHEN the valuation_score from the NormalizedInput is below 0.3, THE Hard_Filter_Engine SHALL produce an immediate SKIP verdict for both pipelines with reason "valuation_below_threshold".
|
||||||
|
3. WHEN the earnings_proximity_days from the NormalizedInput is 5 or fewer, THE Hard_Filter_Engine SHALL produce an immediate SKIP verdict for both pipelines with reason "earnings_block".
|
||||||
|
4. WHEN multiple hard filters trigger simultaneously, THE Hard_Filter_Engine SHALL record all triggered filter reasons in the SKIP verdict (not just the first).
|
||||||
|
5. WHEN no hard filters trigger, THE Hard_Filter_Engine SHALL pass the NormalizedInput through to both pipelines without modification.
|
||||||
|
6. THE Hard_Filter_Engine SHALL execute before either pipeline begins evaluation, and both pipelines SHALL receive the same filter decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 5: Heuristic Pipeline — Deterministic Scoring and Verdict
|
||||||
|
|
||||||
|
**User Story:** As a quantitative analyst, I want the heuristic pipeline to produce a deterministic BUY/WATCH/SKIP verdict based on composite scoring of company, macro, and competitive signals, so that the system maintains a transparent, auditable scoring path.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Heuristic_Pipeline SHALL compute a total score `S_total = S_company + S_macro + S_competitive` using the existing three-layer signal aggregation with the current `WeightedSignal` abstraction.
|
||||||
|
2. THE Heuristic_Pipeline SHALL compute signal weights using the formula `W_signal = gate · recency · credibility · (1 + novelty) · market_context` consistent with the existing `compute_signal_weight` function in `scoring.py`.
|
||||||
|
3. THE Heuristic_Pipeline SHALL compute a confidence value from the existing trend confidence formula incorporating source count, extraction confidence, signal agreement, and contradiction penalty.
|
||||||
|
4. THE Heuristic_Pipeline SHALL produce a BUY verdict WHEN confidence >= 0.70 AND S_total >= 1.2 AND valuation_score >= 0.5 AND macro_bias > 0 AND earnings_proximity_days > 5.
|
||||||
|
5. THE Heuristic_Pipeline SHALL produce a WATCH verdict WHEN confidence >= 0.55 AND the BUY conditions are not fully met.
|
||||||
|
6. THE Heuristic_Pipeline SHALL produce a SKIP verdict WHEN confidence < 0.55.
|
||||||
|
7. THE Heuristic_Pipeline SHALL emit a `HeuristicResult` containing: verdict (BUY/WATCH/SKIP), confidence (float), S_total (float), S_company (float), S_macro (float), S_competitive (float), signal_weights (list), and reasoning (list of strings explaining the verdict).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 6: Probabilistic Pipeline — Bayesian Inference and Verdict
|
||||||
|
|
||||||
|
**User Story:** As a quantitative analyst, I want the probabilistic pipeline to produce a Bayesian BUY/WATCH/SKIP verdict using regime-based priors, likelihood ratios, entropy gating, and expected value calculation, so that the system captures uncertainty structure and risk-adjusted expected outcomes.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Probabilistic_Pipeline SHALL initialize the prior probability based on the current market regime classification: bull regime → P_prior = 0.58, range regime → P_prior = 0.50, bear regime → P_prior = 0.42.
|
||||||
|
2. THE Probabilistic_Pipeline SHALL compute likelihood ratios for each signal using `P(sig|up) = h·s + (1-h)·(1-s)·0.5` and `LR = P(sig|up) / P(sig|down)`, where h is the signal's historical hit rate and s is the signal strength.
|
||||||
|
3. THE Probabilistic_Pipeline SHALL update the posterior using log-odds accumulation: `logit(P_post) = logit(P_prior) + Σ log(LR_i)`, converting back to probability via the sigmoid function.
|
||||||
|
4. THE Probabilistic_Pipeline SHALL compute Shannon entropy `H = -P_up·log₂(P_up) - (1-P_up)·log₂(1-P_up)` and apply entropy gating: WHEN H > 0.95, THE Probabilistic_Pipeline SHALL force a SKIP verdict with reason "high_entropy".
|
||||||
|
5. THE Probabilistic_Pipeline SHALL compute expected value per unit risk as `EV_R = P_up · E[win_R] - (1 - P_up) · 1.0` where `E[win_R]` is the expected win in risk units derived from signal strength and historical reward-risk ratios.
|
||||||
|
6. THE Probabilistic_Pipeline SHALL produce a BUY verdict WHEN P_up >= 0.60 AND entropy <= 0.90 AND EV_R >= 1.5 AND macro_bias > 0 AND valuation_score >= 0.5.
|
||||||
|
7. THE Probabilistic_Pipeline SHALL produce a WATCH verdict WHEN P_up >= 0.55 AND entropy <= 0.95 AND the BUY conditions are not fully met.
|
||||||
|
8. THE Probabilistic_Pipeline SHALL produce a SKIP verdict in all other cases.
|
||||||
|
9. THE Probabilistic_Pipeline SHALL emit a `ProbabilisticResult` containing: verdict (BUY/WATCH/SKIP), P_up (float), entropy (float), EV_R (float), prior (float), posterior (float), likelihood_ratios (list), regime (string), and reasoning (list of strings).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 7: Signal Correlation Penalty — Preventing LR Stacking Inflation
|
||||||
|
|
||||||
|
**User Story:** As a quantitative analyst, I want correlated signals grouped into clusters with a correlation penalty applied to prevent likelihood ratio stacking inflation, so that the Bayesian pipeline does not overstate confidence from redundant signals.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Probabilistic_Pipeline SHALL classify each signal into one of four clusters: momentum (MA stack, RSI), structure (Fibonacci retracement, Elliott Wave), volatility (ATR-based signals, Bollinger-derived), and fundamentals (valuation, earnings, macro).
|
||||||
|
2. WHEN multiple signals within the same cluster produce likelihood ratios in the same direction, THE Probabilistic_Pipeline SHALL apply a within-cluster penalty: only the strongest LR in the cluster contributes at full weight, and subsequent LRs in the same cluster contribute at a decay factor of 0.5^(n-1) where n is the signal's rank within the cluster by LR magnitude.
|
||||||
|
3. THE Probabilistic_Pipeline SHALL apply no penalty across different clusters (signals from different clusters are treated as independent).
|
||||||
|
4. WHEN a cluster contains only one signal, THE Probabilistic_Pipeline SHALL apply no penalty to that signal.
|
||||||
|
5. FOR ALL signal sets, THE Probabilistic_Pipeline SHALL produce a posterior probability that is less than or equal to the posterior computed without the correlation penalty (the penalty only reduces confidence, never inflates it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 8: Exit Engine — Position Management
|
||||||
|
|
||||||
|
**User Story:** As a trader, I want the signal engine to evaluate exit conditions for open positions, so that stop hits, take-profit targets, and trailing stops are managed as part of the signal evaluation cycle.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the current price of an open position hits or crosses below the stop_loss level, THE Exit_Engine SHALL emit an EXIT_FULL signal for that position with reason "stop_hit".
|
||||||
|
2. WHEN the current price of an open position hits or crosses above the first take-profit target (target_1), THE Exit_Engine SHALL emit an EXIT_HALF signal for that position with reason "target_1_hit".
|
||||||
|
3. WHEN the current price of an open position hits or crosses above the second take-profit target (target_2), THE Exit_Engine SHALL emit an EXIT_FULL signal for that position with reason "target_2_hit".
|
||||||
|
4. WHEN a partial exit has been executed (EXIT_HALF), THE Exit_Engine SHALL activate a trailing stop at `current_price - ATR · trailing_multiplier` and update the trailing stop upward as the price advances (the trailing stop moves up but does not move down).
|
||||||
|
5. WHEN the trailing stop is active and the current price crosses below the trailing stop level, THE Exit_Engine SHALL emit an EXIT_FULL signal for the remaining position with reason "trailing_stop_hit".
|
||||||
|
6. THE Exit_Engine SHALL evaluate exit conditions before the signal pipelines run for new entry signals, so that exit signals take priority over new entry signals for the same ticker.
|
||||||
|
7. THE Exit_Engine SHALL emit exit signals as part of the `SignalOutput` contract with the position identifier, exit type (EXIT_HALF/EXIT_FULL), and reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 9: Delta Analyzer — Pipeline Agreement Tracking
|
||||||
|
|
||||||
|
**User Story:** As a model developer, I want the delta analyzer to compare heuristic and probabilistic verdicts and record disagreement details, so that I can generate training signals for future model tuning.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN both pipelines produce verdicts for the same ticker and tick, THE Delta_Analyzer SHALL compute an agreement flag (true if both verdicts are identical, false otherwise).
|
||||||
|
2. THE Delta_Analyzer SHALL compute a confidence delta as `|heuristic_confidence - probabilistic_P_up|` representing the magnitude of disagreement between the two pipelines.
|
||||||
|
3. WHEN the pipelines disagree on verdict, THE Delta_Analyzer SHALL record the disagreement reason by identifying which conditions differed (e.g., "heuristic_confidence_below_threshold", "probabilistic_entropy_too_high", "EV_R_below_threshold").
|
||||||
|
4. THE Delta_Analyzer SHALL track a rolling agreement rate over the last 100 evaluations per ticker, stored in Redis for dashboard consumption.
|
||||||
|
5. THE Delta_Analyzer SHALL emit a `DeltaResult` containing: agreement (bool), confidence_delta (float), heuristic_verdict (string), probabilistic_verdict (string), disagreement_reasons (list of strings), and rolling_agreement_rate (float).
|
||||||
|
6. WHEN the rolling agreement rate drops below 0.50 for a ticker, THE Delta_Analyzer SHALL log a warning indicating persistent pipeline disagreement for operator review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 10: Output Formatter — Structured SignalOutput Contract
|
||||||
|
|
||||||
|
**User Story:** As a downstream system consumer, I want the signal engine to emit a structured `SignalOutput` contract, so that the trading engine, delta analysis dashboard, and audit systems can consume a consistent output format.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Output_Formatter SHALL produce a `SignalOutput` containing: ticker (string), timestamp (datetime), price (float), heuristic section (verdict, confidence, S_total), probabilistic section (verdict, P_up, entropy, EV_R), delta section (agreement, confidence_delta, disagreement_reasons), and optional trade_plan section.
|
||||||
|
2. WHEN the heuristic pipeline produces a BUY verdict, THE Output_Formatter SHALL populate the trade_plan section with entry_price, stop_loss, target_1, target_2, and position_size derived from the heuristic confidence and existing position sizing logic.
|
||||||
|
3. WHEN the probabilistic pipeline produces a BUY verdict but the heuristic pipeline does not, THE Output_Formatter SHALL populate the trade_plan section with a "probabilistic_only" flag and reduced position sizing (50% of standard).
|
||||||
|
4. WHEN both pipelines produce a BUY verdict, THE Output_Formatter SHALL populate the trade_plan section with full position sizing and a "dual_confirmed" flag.
|
||||||
|
5. THE Output_Formatter SHALL serialize the `SignalOutput` as a Pydantic model with JSON serialization support for Redis queue publishing and database persistence.
|
||||||
|
6. FOR ALL valid pipeline results, THE Output_Formatter SHALL produce a `SignalOutput` that round-trips through JSON serialization and deserialization without data loss (parse(format(output)) produces an equivalent object).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 11: Dual Pipeline Orchestration
|
||||||
|
|
||||||
|
**User Story:** As a signal engine operator, I want both pipelines to run concurrently per evaluation tick sharing the same inputs, so that the system produces independent verdicts without redundant data fetching.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN an evaluation tick is triggered, THE Signal_Engine SHALL execute the Input_Normalizer once, then pass the resulting `NormalizedInput` to the Hard_Filter_Engine, then (if not filtered) execute both the Heuristic_Pipeline and Probabilistic_Pipeline concurrently using `asyncio.gather`.
|
||||||
|
2. THE Signal_Engine SHALL enforce that both pipelines receive identical `NormalizedInput` references (no independent data fetches that could produce different snapshots).
|
||||||
|
3. WHEN either pipeline raises an exception during evaluation, THE Signal_Engine SHALL catch the exception, log the error with full traceback, and produce a SKIP verdict for the failed pipeline with reason "pipeline_error" while allowing the other pipeline to complete normally.
|
||||||
|
4. THE Signal_Engine SHALL measure and log the wall-clock execution time of each pipeline per tick for performance monitoring.
|
||||||
|
5. THE Signal_Engine SHALL publish the assembled `SignalOutput` to the existing Redis queue (`stonks:queue:trading_decisions`) for consumption by the trading engine.
|
||||||
|
6. THE Signal_Engine SHALL persist each `SignalOutput` to a database table for historical analysis and audit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 12: Integration with Existing Trading Engine
|
||||||
|
|
||||||
|
**User Story:** As a platform operator, I want the dual-pipeline signal engine to integrate with the existing trading engine, so that the trading engine can consume `SignalOutput` verdicts and make execution decisions.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Signal_Engine SHALL publish `SignalOutput` to the existing `stonks:queue:trading_decisions` Redis queue in a format compatible with the existing `TradingEngine.evaluate_recommendation` interface.
|
||||||
|
2. THE Signal_Engine SHALL map the `SignalOutput` trade_plan to the existing `Recommendation` schema fields (action, confidence, position_sizing) so that the trading engine can process dual-pipeline outputs without modification to its core evaluation logic.
|
||||||
|
3. WHEN the `SignalOutput` has a "dual_confirmed" flag, THE Signal_Engine SHALL set the recommendation confidence to the maximum of heuristic_confidence and probabilistic_P_up.
|
||||||
|
4. WHEN the `SignalOutput` has a "probabilistic_only" flag, THE Signal_Engine SHALL set the recommendation confidence to `probabilistic_P_up · 0.8` (20% confidence haircut for single-pipeline confirmation).
|
||||||
|
5. WHEN neither pipeline produces a BUY verdict, THE Signal_Engine SHALL not publish a trading recommendation to the queue (WATCH and SKIP verdicts are persisted for analysis but not forwarded to the trading engine).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 13: Configuration and Feature Flags
|
||||||
|
|
||||||
|
**User Story:** As a platform operator, I want the dual-pipeline engine configurable via the existing `risk_configs` table and environment variables, so that I can tune thresholds, enable/disable individual pipelines, and adjust timeframe weights without code changes.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Signal_Engine SHALL support a `dual_pipeline_enabled` feature flag in `risk_configs` that toggles the entire dual-pipeline engine on or off, defaulting to false for safe rollout.
|
||||||
|
2. THE Signal_Engine SHALL support independent enable/disable flags for each pipeline: `heuristic_pipeline_enabled` and `probabilistic_pipeline_enabled`, both defaulting to true when the dual-pipeline engine is enabled.
|
||||||
|
3. THE Signal_Engine SHALL support configurable timeframe weights via a `timeframe_weights` JSON object in `risk_configs`, defaulting to `{"M30": 0.03, "H1": 0.07, "H4": 0.15, "D": 0.30, "W": 0.30, "M": 0.15}`.
|
||||||
|
4. THE Signal_Engine SHALL support configurable hard filter thresholds: `hard_filter_valuation_min` (default 0.3), `hard_filter_earnings_days` (default 5), and `hard_filter_macro_bias_skip` (default -1.0).
|
||||||
|
5. THE Signal_Engine SHALL support configurable verdict thresholds for both pipelines via `risk_configs` JSON, including heuristic confidence thresholds (BUY: 0.70, WATCH: 0.55) and probabilistic thresholds (P_up: 0.60, entropy: 0.90, EV_R: 1.5).
|
||||||
|
6. IF the `dual_pipeline_enabled` flag fails to read from the database, THEN THE Signal_Engine SHALL default to disabled (fail-safe behavior) and log a warning.
|
||||||
|
7. THE Signal_Engine SHALL log the active configuration at startup and on each configuration change for auditability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 14: Regime-Based Prior Engine
|
||||||
|
|
||||||
|
**User Story:** As a quantitative analyst, I want the probabilistic pipeline's prior probability to adapt based on the current market regime, so that the Bayesian inference starts from a regime-appropriate baseline rather than a fixed 0.50.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Probabilistic_Pipeline SHALL use the existing `classify_regime` function from `services/aggregation/regime.py` to determine the current market regime for each ticker.
|
||||||
|
2. THE Probabilistic_Pipeline SHALL map regime classifications to prior probabilities: trend_following with positive trend_indicator → 0.58 (bull), trend_following with negative trend_indicator → 0.42 (bear), mean_reversion → 0.50 (range), panic → 0.42 (bear), uncertainty → 0.50 (range).
|
||||||
|
3. THE Probabilistic_Pipeline SHALL convert the regime prior to log-odds before accumulating likelihood ratios: `logit(P_prior) = log(P_prior / (1 - P_prior))`.
|
||||||
|
4. WHEN market data is insufficient for regime classification (fewer than 100 days of price history), THE Probabilistic_Pipeline SHALL use the uncertainty prior of 0.50.
|
||||||
|
5. THE Probabilistic_Pipeline SHALL record the regime classification and prior probability in the `ProbabilisticResult` for auditability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 15: Database Schema for Signal Engine Output
|
||||||
|
|
||||||
|
**User Story:** As a platform operator, I want signal engine outputs persisted to a dedicated database table, so that historical evaluations are available for analysis, backtesting, and audit.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Signal_Engine SHALL persist each `SignalOutput` to a `signal_engine_outputs` table with columns for: id (UUID primary key), ticker (text), evaluated_at (timestamptz), price (numeric), heuristic_verdict (text), heuristic_confidence (numeric), heuristic_s_total (numeric), probabilistic_verdict (text), probabilistic_p_up (numeric), probabilistic_entropy (numeric), probabilistic_ev_r (numeric), delta_agreement (boolean), delta_confidence_delta (numeric), delta_reasons (JSONB), trade_plan (JSONB), full_output (JSONB), created_at (timestamptz).
|
||||||
|
2. THE Signal_Engine SHALL create an index on `(ticker, evaluated_at)` for efficient time-range queries per ticker.
|
||||||
|
3. THE Signal_Engine SHALL create an index on `evaluated_at` for efficient global time-range queries.
|
||||||
|
4. WHEN persisting fails due to a database error, THE Signal_Engine SHALL log the error and continue processing (persistence failure does not block signal emission to the trading queue).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 16: Backward Compatibility and Migration Path
|
||||||
|
|
||||||
|
**User Story:** As a platform operator, I want the dual-pipeline engine to coexist with the existing single-pipeline aggregation, so that the rollout is incremental and reversible.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN `dual_pipeline_enabled` is false, THE Signal_Engine SHALL not run, and the existing aggregation pipeline SHALL continue to operate unchanged.
|
||||||
|
2. WHEN `dual_pipeline_enabled` is true, THE Signal_Engine SHALL run alongside the existing aggregation pipeline, with the trading engine consuming `SignalOutput` from the dual-pipeline engine instead of `Recommendation` from the existing recommendation worker.
|
||||||
|
3. THE Signal_Engine SHALL reuse the existing `WeightedSignal`, `BayesianPosterior`, `RegimeClassification`, and `TrendSummary` data structures from `services/aggregation/` rather than duplicating them.
|
||||||
|
4. THE Signal_Engine SHALL reuse the existing `compute_signal_weight`, `compute_bayesian_posterior`, and `classify_regime` functions rather than reimplementing the underlying math.
|
||||||
|
5. THE Signal_Engine SHALL add the new `signal_engine_outputs` table via a new database migration without modifying existing tables.
|
||||||
|
6. THE Signal_Engine SHALL support running in "shadow mode" where both the existing pipeline and the dual-pipeline engine run, but only the existing pipeline's output is forwarded to the trading engine (dual-pipeline output is persisted for comparison only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 17: Property-Based Testing for Dual-Pipeline Correctness
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want comprehensive property-based tests validating the mathematical correctness and structural invariants of the dual-pipeline engine, so that edge cases and numerical stability issues are caught before deployment.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE test suite SHALL include property-based tests for the Fibonacci retracement formula verifying that `L(r) = SH - r·(SH - SL)` produces values in [SL, SH] for all r in [0, 1] and all SH > SL > 0.
|
||||||
|
2. THE test suite SHALL include property-based tests for the Bayesian log-odds update verifying that `logit(P_post) = logit(P_prior) + Σ log(LR_i)` round-trips correctly: converting P_prior to logit, adding log-LRs, and converting back via sigmoid produces a valid probability in (0, 1).
|
||||||
|
3. THE test suite SHALL include property-based tests for the entropy gate verifying that Shannon entropy is maximized at P_up = 0.5 and equals 0.0 at P_up = 0.0 or P_up = 1.0, and is symmetric around 0.5.
|
||||||
|
4. THE test suite SHALL include property-based tests for the signal correlation penalty verifying that the penalized posterior is always less than or equal to the unpenalized posterior for any signal set with correlated signals.
|
||||||
|
5. THE test suite SHALL include property-based tests for the multi-timeframe confluence score verifying monotonicity: activating a signal on an additional timeframe with non-zero weight always increases or maintains the confluence score.
|
||||||
|
6. THE test suite SHALL include property-based tests for the `SignalOutput` contract verifying round-trip serialization: `SignalOutput.model_validate_json(output.model_dump_json())` produces an equivalent object for all valid outputs.
|
||||||
|
7. THE test suite SHALL include property-based tests for the hard filter engine verifying that macro_bias = -1.0 always produces SKIP, valuation_score < 0.3 always produces SKIP, and earnings_proximity_days <= 5 always produces SKIP, regardless of all other input values.
|
||||||
|
8. THE test suite SHALL include property-based tests for the EV_R calculation verifying that `EV_R = P_up · E[win_R] - (1 - P_up) · 1.0` is monotonically increasing with P_up for fixed E[win_R] > 0.
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# Implementation Plan: Dual-Pipeline Signal Engine
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement the dual-pipeline signal engine as a new service at `services/signal_engine/` that runs as an independent Kubernetes deployment. The engine evaluates both a heuristic (deterministic scoring) and probabilistic (Bayesian inference) pipeline concurrently per ticker per evaluation tick, producing independent BUY/WATCH/SKIP verdicts. Implementation proceeds incrementally: infrastructure first, then core models, signal library, pipelines, orchestration, integration, and deployment.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Project scaffolding, configuration, and data models
|
||||||
|
- [x] 1.1 Create service directory structure and `__init__.py` files
|
||||||
|
- Create `services/signal_engine/` with all subdirectories per the design module structure
|
||||||
|
- Create `services/signal_engine/__init__.py`, `services/signal_engine/signals/__init__.py`
|
||||||
|
- _Requirements: 11.1, 13.1_
|
||||||
|
|
||||||
|
- [x] 1.2 Implement `models.py` — all Pydantic data models
|
||||||
|
- Define `OHLCVBar`, `NormalizedInput`, `OpenPositionState`, `SignalResult`, `SignalDirection`
|
||||||
|
- Define `ConfluenceSignal`, `Verdict`, `HeuristicResult`, `LikelihoodRatio`, `ProbabilisticResult`
|
||||||
|
- Define `DeltaResult`, `ExitSignal`, `ExitType`, `TradePlan`, `SignalOutput`
|
||||||
|
- All models must use Pydantic `BaseModel` with proper field constraints (`ge`, `le`)
|
||||||
|
- _Requirements: 1.1, 2.7, 5.7, 6.9, 9.5, 10.1, 10.5_
|
||||||
|
|
||||||
|
- [x] 1.3 Implement `config.py` — `SignalEngineConfig` and sub-configs
|
||||||
|
- Define `SignalEngineConfig` dataclass with all fields from the design
|
||||||
|
- Define `HardFilterConfig`, `HeuristicConfig`, `ProbabilisticConfig`, `ExitConfig` as derived sub-configs
|
||||||
|
- Implement `load_config()` that reads from `risk_configs` table + environment variables
|
||||||
|
- Default `dual_pipeline_enabled` to `False` (fail-safe)
|
||||||
|
- _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7_
|
||||||
|
|
||||||
|
- [x] 1.4 Add `QUEUE_SIGNAL_ENGINE` to `services/shared/redis_keys.py`
|
||||||
|
- Add `QUEUE_SIGNAL_ENGINE = "signal_engine"` constant
|
||||||
|
- _Requirements: 11.1_
|
||||||
|
|
||||||
|
- [x] 1.5 Write property test for `SignalOutput` round-trip serialization
|
||||||
|
- **Requirement 17.6: SignalOutput round-trip serialization**
|
||||||
|
- Generate arbitrary valid `SignalOutput` instances with Hypothesis
|
||||||
|
- Verify `SignalOutput.model_validate_json(output.model_dump_json())` produces equivalent object
|
||||||
|
- File: `tests/test_pbt_signal_engine_models.py`
|
||||||
|
- _Requirements: 10.5, 17.6_
|
||||||
|
|
||||||
|
- [x] 2. Input Normalizer and Hard Filter Engine
|
||||||
|
- [x] 2.1 Implement `normalizer.py` — Input Normalizer
|
||||||
|
- Implement `normalize_input(pool, ticker, config) -> NormalizedInput`
|
||||||
|
- Fetch OHLCV bars from `market_data_bars` for M30, H1, H4, D, W, M timeframes
|
||||||
|
- Fetch fundamental metrics (valuation_score, earnings_proximity_days) from company/trend data
|
||||||
|
- Fetch macro context (macro_bias) from `macro_impact_records` and `global_events`
|
||||||
|
- Fetch open position state from trading engine portfolio tables
|
||||||
|
- Populate sentinel values (`None`, empty list) for unavailable data with logged warnings
|
||||||
|
- Validate monotonically increasing timestamps within each timeframe series
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||||
|
|
||||||
|
- [x] 2.2 Implement `hard_filter.py` — Hard Filter Engine
|
||||||
|
- Implement `evaluate_hard_filters(normalized, config) -> HardFilterResult`
|
||||||
|
- Check `macro_bias == -1.0` → SKIP with reason "macro_bias_negative"
|
||||||
|
- Check `valuation_score < 0.3` → SKIP with reason "valuation_below_threshold"
|
||||||
|
- Check `earnings_proximity_days <= 5` → SKIP with reason "earnings_block"
|
||||||
|
- Record all triggered filter reasons (not just first)
|
||||||
|
- Return `HardFilterResult` with `filtered: bool` and `reasons: list[str]`
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||||
|
|
||||||
|
- [x] 2.3 Write property tests for hard filter engine
|
||||||
|
- **Requirement 17.7: Hard filter determinism**
|
||||||
|
- Generate arbitrary `NormalizedInput` with `macro_bias = -1.0` → always SKIP
|
||||||
|
- Generate arbitrary `NormalizedInput` with `valuation_score < 0.3` → always SKIP
|
||||||
|
- Generate arbitrary `NormalizedInput` with `earnings_proximity_days <= 5` → always SKIP
|
||||||
|
- Verify these hold regardless of all other input values
|
||||||
|
- File: `tests/test_pbt_signal_engine_hard_filter.py`
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 17.7_
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Signal Library — Technical Signal Evaluators
|
||||||
|
- [x] 4.1 Implement `signals/base.py` — SignalEvaluator protocol
|
||||||
|
- Define `SignalEvaluator` protocol with `evaluate(bars, timeframe) -> SignalResult | None`
|
||||||
|
- Define common helper functions for swing high/low detection, lookback validation
|
||||||
|
- _Requirements: 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 4.2 Implement `signals/fibonacci.py` — Fibonacci retracement evaluator
|
||||||
|
- Implement `L(r) = SH - r·(SH - SL)` for ratios [0.236, 0.382, 0.5, 0.618, 0.786]
|
||||||
|
- Detect swing high and swing low within the evaluation window
|
||||||
|
- Produce signal strength based on proximity of current price to retracement levels
|
||||||
|
- Return `None` with reason code when insufficient data
|
||||||
|
- _Requirements: 2.1, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 4.3 Write property test for Fibonacci retracement formula
|
||||||
|
- **Requirement 17.1: Fibonacci retracement bounds**
|
||||||
|
- For all `r` in [0, 1] and all `SH > SL > 0`, verify `L(r)` is in [SL, SH]
|
||||||
|
- File: `tests/test_pbt_signal_engine_fibonacci.py`
|
||||||
|
- _Requirements: 2.1, 17.1_
|
||||||
|
|
||||||
|
- [x] 4.4 Implement `signals/ma_stack.py` — Moving average stack evaluator
|
||||||
|
- Detect bullish alignment (MA_10 > MA_20 > MA_50 > MA_200)
|
||||||
|
- Detect bearish alignment (MA_10 < MA_20 < MA_50 < MA_200)
|
||||||
|
- Produce signal strength proportional to degree of alignment
|
||||||
|
- Return `None` when insufficient bars for MA_200 calculation
|
||||||
|
- _Requirements: 2.2, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 4.5 Implement `signals/rsi.py` — RSI evaluator
|
||||||
|
- Implement standard 14-period RSI formula
|
||||||
|
- Produce overbought signals (RSI > 70) and oversold signals (RSI < 30)
|
||||||
|
- Scale strength by distance from threshold
|
||||||
|
- Return `None` when fewer than 14 bars available
|
||||||
|
- _Requirements: 2.3, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 4.6 Implement `signals/cup_handle.py` — Cup & Handle pattern detector
|
||||||
|
- Identify cup formation (U-shaped price recovery) and handle (small consolidation)
|
||||||
|
- Produce signal with confidence proportional to pattern completeness
|
||||||
|
- Return `None` when insufficient data or no pattern detected
|
||||||
|
- _Requirements: 2.4, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 4.7 Implement `signals/elliott_wave.py` — Elliott Wave detector
|
||||||
|
- Identify impulse waves (5-wave structure) and corrective waves (3-wave structure)
|
||||||
|
- Produce signal with current wave position and projected direction
|
||||||
|
- Return `None` when insufficient data or ambiguous wave count
|
||||||
|
- _Requirements: 2.5, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 5. Multi-Timeframe Confluence Engine
|
||||||
|
- [x] 5.1 Implement `confluence.py` — Multi-Timeframe Engine
|
||||||
|
- Implement `compute_confluence(signal_results, weights) -> list[ConfluenceSignal]`
|
||||||
|
- Compute weighted confluence score: `C_confluence = Σ(w_tf · s_tf)`
|
||||||
|
- Apply minimum confluence threshold: discard signals triggering on < 2 timeframes
|
||||||
|
- Apply higher-timeframe anchor: discard signals without at least one of D, W, or M
|
||||||
|
- Return `ConfluenceSignal` objects with active timeframes and per-timeframe strengths
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||||
|
|
||||||
|
- [x] 5.2 Write property test for confluence score monotonicity
|
||||||
|
- **Requirement 17.5: Confluence score monotonicity**
|
||||||
|
- Verify that activating a signal on an additional timeframe with non-zero weight always increases or maintains the confluence score
|
||||||
|
- File: `tests/test_pbt_signal_engine_confluence.py`
|
||||||
|
- _Requirements: 3.6, 17.5_
|
||||||
|
|
||||||
|
- [x] 6. Checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 7. Heuristic Pipeline (Pipeline A)
|
||||||
|
- [x] 7.1 Implement `heuristic.py` — Heuristic Pipeline
|
||||||
|
- Implement `run_heuristic_pipeline(normalized, confluence_signals, config) -> HeuristicResult`
|
||||||
|
- Compute `S_total = S_company + S_macro + S_competitive` using existing `compute_signal_weight()`
|
||||||
|
- Compute confidence from source count, extraction confidence, signal agreement, contradiction penalty
|
||||||
|
- BUY verdict: confidence >= 0.70 AND S_total >= 1.2 AND valuation_score >= 0.5 AND macro_bias > 0 AND earnings_proximity_days > 5
|
||||||
|
- WATCH verdict: confidence >= 0.55 AND BUY conditions not fully met
|
||||||
|
- SKIP verdict: confidence < 0.55
|
||||||
|
- Emit `HeuristicResult` with all required fields and reasoning
|
||||||
|
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
|
||||||
|
|
||||||
|
- [x] 7.2 Write unit tests for heuristic pipeline verdict logic
|
||||||
|
- Test BUY threshold conditions
|
||||||
|
- Test WATCH threshold conditions
|
||||||
|
- Test SKIP conditions
|
||||||
|
- Test edge cases at threshold boundaries
|
||||||
|
- File: `tests/test_signal_engine_heuristic.py`
|
||||||
|
- _Requirements: 5.4, 5.5, 5.6_
|
||||||
|
|
||||||
|
- [x] 8. Probabilistic Pipeline (Pipeline B) and Correlation Penalty
|
||||||
|
- [x] 8.1 Implement `correlation.py` — Signal cluster classification and penalty
|
||||||
|
- Define `SignalCluster` enum: MOMENTUM, STRUCTURE, VOLATILITY, FUNDAMENTALS
|
||||||
|
- Implement `classify_signal(signal_type) -> SignalCluster`
|
||||||
|
- Implement `apply_correlation_penalty(likelihood_ratios) -> list[LikelihoodRatio]`
|
||||||
|
- Within-cluster decay: strongest LR at full weight, subsequent at 0.5^(n-1)
|
||||||
|
- No penalty across different clusters
|
||||||
|
- Single-signal clusters receive no penalty
|
||||||
|
- _Requirements: 7.1, 7.2, 7.3, 7.4_
|
||||||
|
|
||||||
|
- [x] 8.2 Implement `probabilistic.py` — Probabilistic Pipeline
|
||||||
|
- Implement `run_probabilistic_pipeline(normalized, confluence_signals, regime, config) -> ProbabilisticResult`
|
||||||
|
- Initialize regime-based prior: bull=0.58, range=0.50, bear=0.42
|
||||||
|
- Compute likelihood ratios: `P(sig|up) = h·s + (1-h)·(1-s)·0.5`, `LR = P(sig|up) / P(sig|down)`
|
||||||
|
- Apply correlation penalty via `apply_correlation_penalty()`
|
||||||
|
- Accumulate via log-odds: `logit(P_post) = logit(P_prior) + Σ log(LR_i)`
|
||||||
|
- Compute Shannon entropy and apply entropy gating (H > 0.95 → SKIP)
|
||||||
|
- Compute `EV_R = P_up · E[win_R] - (1 - P_up) · 1.0`
|
||||||
|
- BUY: P_up >= 0.60 AND entropy <= 0.90 AND EV_R >= 1.5 AND macro_bias > 0 AND valuation_score >= 0.5
|
||||||
|
- WATCH: P_up >= 0.55 AND entropy <= 0.95 AND BUY conditions not fully met
|
||||||
|
- SKIP: all other cases
|
||||||
|
- Use existing `classify_regime()` from `services/aggregation/regime.py`
|
||||||
|
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9, 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||||
|
|
||||||
|
- [x] 8.3 Write property test for Bayesian log-odds round-trip
|
||||||
|
- **Requirement 17.2: Bayesian log-odds update correctness**
|
||||||
|
- Verify `logit(P_post) = logit(P_prior) + Σ log(LR_i)` round-trips correctly
|
||||||
|
- Converting P_prior to logit, adding log-LRs, converting back via sigmoid produces valid probability in (0, 1)
|
||||||
|
- File: `tests/test_pbt_signal_engine_bayesian.py`
|
||||||
|
- _Requirements: 6.3, 17.2_
|
||||||
|
|
||||||
|
- [x] 8.4 Write property test for entropy gate
|
||||||
|
- **Requirement 17.3: Entropy gate properties**
|
||||||
|
- Verify Shannon entropy is maximized at P_up = 0.5
|
||||||
|
- Verify entropy equals 0.0 at P_up = 0.0 or P_up = 1.0
|
||||||
|
- Verify entropy is symmetric around 0.5
|
||||||
|
- File: `tests/test_pbt_signal_engine_bayesian.py`
|
||||||
|
- _Requirements: 6.4, 17.3_
|
||||||
|
|
||||||
|
- [x] 8.5 Write property test for signal correlation penalty
|
||||||
|
- **Requirement 17.4: Correlation penalty reduces confidence**
|
||||||
|
- Verify penalized posterior is always <= unpenalized posterior for any signal set with correlated signals
|
||||||
|
- File: `tests/test_pbt_signal_engine_correlation.py`
|
||||||
|
- _Requirements: 7.5, 17.4_
|
||||||
|
|
||||||
|
- [x] 8.6 Write property test for EV_R monotonicity
|
||||||
|
- **Requirement 17.8: EV_R monotonically increasing with P_up**
|
||||||
|
- Verify `EV_R = P_up · E[win_R] - (1 - P_up) · 1.0` is monotonically increasing with P_up for fixed E[win_R] > 0
|
||||||
|
- File: `tests/test_pbt_signal_engine_bayesian.py`
|
||||||
|
- _Requirements: 6.5, 17.8_
|
||||||
|
|
||||||
|
- [x] 9. Checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 10. Exit Engine
|
||||||
|
- [x] 10.1 Implement `exit_engine.py` — Exit Engine
|
||||||
|
- Implement `evaluate_exits(positions, current_prices, config) -> list[ExitSignal]`
|
||||||
|
- Check stop_loss hit → EXIT_FULL with reason "stop_hit"
|
||||||
|
- Check target_1 hit → EXIT_HALF with reason "target_1_hit"
|
||||||
|
- Check target_2 hit → EXIT_FULL with reason "target_2_hit"
|
||||||
|
- Trailing stop: activate after EXIT_HALF at `current_price - ATR · trailing_multiplier`
|
||||||
|
- Trailing stop ratchets upward only (never moves down)
|
||||||
|
- Trailing stop hit → EXIT_FULL with reason "trailing_stop_hit"
|
||||||
|
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7_
|
||||||
|
|
||||||
|
- [x] 10.2 Write unit tests for exit engine
|
||||||
|
- Test stop_loss trigger
|
||||||
|
- Test target_1 partial exit
|
||||||
|
- Test target_2 full exit
|
||||||
|
- Test trailing stop activation and ratchet behavior
|
||||||
|
- File: `tests/test_signal_engine_exit.py`
|
||||||
|
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_
|
||||||
|
|
||||||
|
- [x] 11. Delta Analyzer and Output Formatter
|
||||||
|
- [x] 11.1 Implement `delta.py` — Delta Analyzer
|
||||||
|
- Implement `analyze_delta(heuristic, probabilistic, redis, ticker) -> DeltaResult`
|
||||||
|
- Compute agreement flag (both verdicts identical)
|
||||||
|
- Compute confidence delta: `|heuristic_confidence - probabilistic_P_up|`
|
||||||
|
- Record disagreement reasons when verdicts differ
|
||||||
|
- Track rolling 100-evaluation agreement rate in Redis
|
||||||
|
- Log warning when agreement rate drops below 0.50
|
||||||
|
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6_
|
||||||
|
|
||||||
|
- [x] 11.2 Implement `formatter.py` — Output Formatter
|
||||||
|
- Implement `format_output(ticker, price, heuristic, probabilistic, delta, exit_signals, config) -> SignalOutput`
|
||||||
|
- Both BUY → `dual_confirmed`, full position sizing
|
||||||
|
- Probabilistic-only BUY → `probabilistic_only`, 50% position sizing
|
||||||
|
- Heuristic-only BUY → standard position sizing
|
||||||
|
- No BUY → no trade_plan (WATCH/SKIP persisted for analysis)
|
||||||
|
- Implement `signal_output_to_recommendation(output) -> Recommendation`
|
||||||
|
- Map `SignalOutput` to existing `Recommendation` schema for trading engine compatibility
|
||||||
|
- Dual confirmed: confidence = max(heuristic_confidence, probabilistic_P_up)
|
||||||
|
- Probabilistic only: confidence = probabilistic_P_up · 0.8 (20% haircut)
|
||||||
|
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 12.1, 12.2, 12.3, 12.4, 12.5_
|
||||||
|
|
||||||
|
- [x] 11.3 Write unit tests for output formatter
|
||||||
|
- Test dual_confirmed trade plan generation
|
||||||
|
- Test probabilistic_only trade plan with 50% sizing
|
||||||
|
- Test heuristic-only trade plan
|
||||||
|
- Test no-BUY case (no trade_plan)
|
||||||
|
- Test `signal_output_to_recommendation` mapping
|
||||||
|
- File: `tests/test_signal_engine_formatter.py`
|
||||||
|
- _Requirements: 10.2, 10.3, 10.4, 12.3, 12.4_
|
||||||
|
|
||||||
|
- [x] 12. Orchestrator, Persistence, and Main Entry Point
|
||||||
|
- [x] 12.1 Implement `persistence.py` — Database persistence
|
||||||
|
- Implement `persist_signal_output(pool, output) -> None`
|
||||||
|
- Insert into `signal_engine_outputs` table
|
||||||
|
- Log and continue on database errors (non-blocking)
|
||||||
|
- _Requirements: 15.1, 15.4_
|
||||||
|
|
||||||
|
- [x] 12.2 Implement `worker.py` — Top-level orchestrator
|
||||||
|
- Implement `evaluate_tick(pool, redis, ticker, config) -> SignalOutput | None`
|
||||||
|
- Step 1: Normalize inputs (single fetch, shared reference)
|
||||||
|
- Step 2: Evaluate exit conditions for open positions
|
||||||
|
- Step 3: Run hard filters (short-circuit if filtered)
|
||||||
|
- Step 4: Evaluate signals across timeframes via Signal Library
|
||||||
|
- Step 5: Compute confluence
|
||||||
|
- Step 6: Classify regime via existing `classify_regime()`
|
||||||
|
- Step 7: Run both pipelines concurrently via `asyncio.gather` with exception handling
|
||||||
|
- Step 8: Compute delta analysis
|
||||||
|
- Step 9: Format output
|
||||||
|
- Step 10: Persist to database and publish to Redis queue
|
||||||
|
- Catch pipeline exceptions → SKIP verdict for failed pipeline, other continues
|
||||||
|
- Measure and log wall-clock execution time per pipeline
|
||||||
|
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6_
|
||||||
|
|
||||||
|
- [x] 12.3 Implement `main.py` — Entry point with asyncio event loop
|
||||||
|
- Connect to PostgreSQL (asyncpg pool) and Redis (redis.asyncio)
|
||||||
|
- Load config from `risk_configs` table
|
||||||
|
- Log active configuration at startup
|
||||||
|
- Poll `stonks:queue:signal_engine` queue indefinitely
|
||||||
|
- Check `dual_pipeline_enabled` flag; if disabled, sleep and retry
|
||||||
|
- On config read failure, default to disabled (fail-safe)
|
||||||
|
- Support shadow mode (persist but don't forward to trading queue)
|
||||||
|
- _Requirements: 13.1, 13.6, 13.7, 16.1, 16.6_
|
||||||
|
|
||||||
|
- [x] 12.4 Write integration tests for worker orchestration
|
||||||
|
- Test full tick evaluation with mocked DB/Redis
|
||||||
|
- Test pipeline failure isolation (one fails, other completes)
|
||||||
|
- Test hard filter short-circuit
|
||||||
|
- Test shadow mode behavior
|
||||||
|
- File: `tests/test_signal_engine_worker.py`
|
||||||
|
- _Requirements: 11.3, 16.6_
|
||||||
|
|
||||||
|
- [x] 13. Checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 14. Database migration and infrastructure
|
||||||
|
- [x] 14.1 Create database migration `infra/migrations/039_signal_engine_outputs.sql`
|
||||||
|
- Create `signal_engine_outputs` table per the design schema
|
||||||
|
- Create index on `(ticker, evaluated_at)` for per-ticker time-range queries
|
||||||
|
- Create index on `evaluated_at` for global time-range queries
|
||||||
|
- Create index on `(heuristic_verdict, probabilistic_verdict)` for verdict filtering
|
||||||
|
- _Requirements: 15.1, 15.2, 15.3_
|
||||||
|
|
||||||
|
- [x] 14.2 Add signal engine service to Helm chart
|
||||||
|
- Add `signalEngine` entry to `infra/helm/stonks-oracle/values.yaml`
|
||||||
|
- Configure: replicas=1, command=`python -m services.signal_engine.main`, tier=processing
|
||||||
|
- Set resource requests/limits per design (100m/128Mi → 500m/256Mi)
|
||||||
|
- Reference existing secrets: `stonks-core-secrets`, `stonks-market-secrets`
|
||||||
|
- _Requirements: 11.1, 13.1_
|
||||||
|
|
||||||
|
- [x] 15. Trading engine integration and backward compatibility
|
||||||
|
- [x] 15.1 Wire signal engine output to trading engine queue
|
||||||
|
- Publish `SignalOutput` (mapped to `Recommendation`) to `stonks:queue:trading_decisions`
|
||||||
|
- Only publish when at least one pipeline produces BUY verdict
|
||||||
|
- WATCH/SKIP verdicts persisted for analysis but not forwarded
|
||||||
|
- Ensure trading engine can consume without modification via `signal_output_to_recommendation()`
|
||||||
|
- _Requirements: 12.1, 12.2, 12.5, 16.2_
|
||||||
|
|
||||||
|
- [x] 15.2 Ensure backward compatibility with existing pipeline
|
||||||
|
- Verify `dual_pipeline_enabled=false` means signal engine does not run
|
||||||
|
- Verify existing aggregation pipeline operates unchanged when flag is off
|
||||||
|
- Reuse existing `WeightedSignal`, `BayesianPosterior`, `RegimeClassification` (import, don't duplicate)
|
||||||
|
- Reuse existing `compute_signal_weight`, `compute_bayesian_posterior`, `classify_regime` functions
|
||||||
|
- No modifications to existing tables (new migration only adds new table)
|
||||||
|
- _Requirements: 16.1, 16.2, 16.3, 16.4, 16.5_
|
||||||
|
|
||||||
|
- [x] 16. Final checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation between major phases
|
||||||
|
- Property-based tests use Hypothesis with `@settings(max_examples=100)` per project conventions
|
||||||
|
- PBT test files are prefixed `test_pbt_*` per project conventions
|
||||||
|
- The service reuses existing math functions from `services/aggregation/` — no reimplementation
|
||||||
|
- All configuration is loaded from `risk_configs` table with fail-safe defaults
|
||||||
|
- Shadow mode allows running alongside existing pipeline without affecting trading decisions
|
||||||
+102
-11
@@ -1,6 +1,6 @@
|
|||||||
# AI Agent Building Guide
|
# AI Agent Building Guide
|
||||||
|
|
||||||
Stonks Oracle uses three AI agents powered by a local Ollama instance. Each agent has a dedicated purpose in the pipeline, a database-backed configuration, and support for A/B testing through variants. This guide covers how each agent works, how to configure them, how to create and test variants, and how to monitor performance.
|
Stonks Oracle uses three AI agents powered by local LLM inference (Ollama or vLLM). Each agent has a dedicated purpose in the pipeline, a database-backed configuration, and support for A/B testing through variants. This guide covers how each agent works, how to configure them, how to create and test variants, and how to monitor performance.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ Stonks Oracle uses three AI agents powered by a local Ollama instance. Each agen
|
|||||||
- [Document Intelligence Extractor](#1-document-intelligence-extractor)
|
- [Document Intelligence Extractor](#1-document-intelligence-extractor)
|
||||||
- [Global Event Classifier](#2-global-event-classifier)
|
- [Global Event Classifier](#2-global-event-classifier)
|
||||||
- [Thesis Rewriter](#3-thesis-rewriter)
|
- [Thesis Rewriter](#3-thesis-rewriter)
|
||||||
|
- [LLM Provider Abstraction](#llm-provider-abstraction)
|
||||||
- [Database Schema](#database-schema)
|
- [Database Schema](#database-schema)
|
||||||
- [ai_agents Table](#ai_agents-table)
|
- [ai_agents Table](#ai_agents-table)
|
||||||
- [agent_variants Table](#agent_variants-table)
|
- [agent_variants Table](#agent_variants-table)
|
||||||
@@ -30,9 +31,10 @@ Three agents are seeded into the `ai_agents` table on first migration (migration
|
|||||||
| **Slug** | `document-extractor` |
|
| **Slug** | `document-extractor` |
|
||||||
| **Purpose** | Extracts structured intelligence (sentiment, catalysts, impact scores, key facts, risks) from company news, SEC filings, earnings transcripts, and press releases |
|
| **Purpose** | Extracts structured intelligence (sentiment, catalysts, impact scores, key facts, risks) from company news, SEC filings, earnings transcripts, and press releases |
|
||||||
| **Default Model** | `qwen3.5:9b-fast` (Ollama) |
|
| **Default Model** | `qwen3.5:9b-fast` (Ollama) |
|
||||||
|
| **Supported Providers** | `ollama`, `vllm` |
|
||||||
| **Prompt Version** | `document-intel-v2` |
|
| **Prompt Version** | `document-intel-v2` |
|
||||||
| **Schema Version** | `2.0.0` |
|
| **Schema Version** | `2.0.0` |
|
||||||
| **Entry Point** | `services/extractor/main.py` → `services/extractor/client.py` |
|
| **Entry Point** | `services/extractor/main.py` → `services/extractor/llm_factory.py` → `services/extractor/client.py` (Ollama) or `services/extractor/vllm_client.py` (vLLM) |
|
||||||
|
|
||||||
**Input Data:**
|
**Input Data:**
|
||||||
- Normalized document text (fetched from MinIO or passed in the Redis job payload)
|
- Normalized document text (fetched from MinIO or passed in the Redis job payload)
|
||||||
@@ -40,7 +42,7 @@ Three agents are seeded into the `ai_agents` table on first migration (migration
|
|||||||
- List of tracked tickers for company identification
|
- List of tracked tickers for company identification
|
||||||
- Document ID for traceability
|
- Document ID for traceability
|
||||||
|
|
||||||
**Output Schema** (`ExtractionResult`):
|
**Output Schema** (`ExtractionResult` — defined in `services/extractor/schemas.py`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -81,6 +83,7 @@ Use "other" for catalyst_type if unsure. Keep evidence_spans short
|
|||||||
- Includes tracked ticker list with rules for company identification
|
- Includes tracked ticker list with rules for company identification
|
||||||
- Includes the full JSON schema field descriptions
|
- Includes the full JSON schema field descriptions
|
||||||
- Truncates documents to 8,000 characters to limit inference time
|
- Truncates documents to 8,000 characters to limit inference time
|
||||||
|
- When an active variant has `input_token_limit > 0`, truncation uses `input_token_limit * 4` characters instead
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ Use "other" for catalyst_type if unsure. Keep evidence_spans short
|
|||||||
| **Slug** | `event-classifier` |
|
| **Slug** | `event-classifier` |
|
||||||
| **Purpose** | Classifies global/geopolitical news into structured macro events with impact type, severity, affected regions/sectors/commodities, and estimated duration |
|
| **Purpose** | Classifies global/geopolitical news into structured macro events with impact type, severity, affected regions/sectors/commodities, and estimated duration |
|
||||||
| **Default Model** | `qwen3.5:9b-fast` (Ollama) |
|
| **Default Model** | `qwen3.5:9b-fast` (Ollama) |
|
||||||
|
| **Supported Providers** | `ollama`, `vllm` |
|
||||||
| **Prompt Version** | `event-classification-v1` |
|
| **Prompt Version** | `event-classification-v1` |
|
||||||
| **Schema Version** | `1.0.0` |
|
| **Schema Version** | `1.0.0` |
|
||||||
| **Entry Point** | `services/extractor/main.py` → `services/extractor/event_classifier.py` |
|
| **Entry Point** | `services/extractor/main.py` → `services/extractor/event_classifier.py` |
|
||||||
@@ -99,7 +103,7 @@ Use "other" for catalyst_type if unsure. Keep evidence_spans short
|
|||||||
- Normalized text of a macro news article (from the `stonks:queue:macro_classification` Redis queue)
|
- Normalized text of a macro news article (from the `stonks:queue:macro_classification` Redis queue)
|
||||||
- Document ID for traceability
|
- Document ID for traceability
|
||||||
|
|
||||||
**Output Schema** (`GlobalEvent`):
|
**Output Schema** (`GlobalEvent` — defined in `services/extractor/event_classifier.py`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -141,9 +145,11 @@ as empty arrays.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**User Prompt Template** (built by `build_event_classification_prompt()` in `services/extractor/event_classifier.py`):
|
**User Prompt Template** (built by `build_event_classification_prompt()` in `services/extractor/event_classifier.py`):
|
||||||
- Includes anti-hallucination rules
|
- Includes anti-hallucination rules (no fabrication, severity "critical" reserved for multi-country events)
|
||||||
- Lists all valid enum values for each field
|
- Lists all valid enum values for each field
|
||||||
- Truncates articles to 6,000 characters
|
- Truncates articles to 6,000 characters
|
||||||
|
- When an active variant has `input_token_limit > 0`, truncation uses `input_token_limit * 4` characters instead
|
||||||
|
- If a variant overrides the system prompt, the classifier ensures JSON output instructions are always appended if not already present
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -154,6 +160,7 @@ as empty arrays.
|
|||||||
| **Slug** | `thesis-rewriter` |
|
| **Slug** | `thesis-rewriter` |
|
||||||
| **Purpose** | Rewrites deterministic trade thesis summaries into clear, professional analyst prose. Optional layer — the system falls back to the deterministic thesis if this fails |
|
| **Purpose** | Rewrites deterministic trade thesis summaries into clear, professional analyst prose. Optional layer — the system falls back to the deterministic thesis if this fails |
|
||||||
| **Default Model** | `qwen3.5:9b-fast` (Ollama) |
|
| **Default Model** | `qwen3.5:9b-fast` (Ollama) |
|
||||||
|
| **Supported Providers** | `ollama`, `vllm` |
|
||||||
| **Prompt Version** | `thesis-rewrite-v1` |
|
| **Prompt Version** | `thesis-rewrite-v1` |
|
||||||
| **Schema Version** | `1.0.0` |
|
| **Schema Version** | `1.0.0` |
|
||||||
| **Entry Point** | `services/recommendation/main.py` → `services/recommendation/thesis_llm.py` |
|
| **Entry Point** | `services/recommendation/main.py` → `services/recommendation/thesis_llm.py` |
|
||||||
@@ -165,6 +172,7 @@ as empty arrays.
|
|||||||
**Output Schema:**
|
**Output Schema:**
|
||||||
- Plain text (not JSON). The model returns only the rewritten thesis as a string, under 150 words.
|
- Plain text (not JSON). The model returns only the rewritten thesis as a string, under 150 words.
|
||||||
- On failure or empty response, the original deterministic thesis is returned unchanged.
|
- On failure or empty response, the original deterministic thesis is returned unchanged.
|
||||||
|
- A `_strip_thinking_block()` post-processor removes `<think>` XML tags and "Thinking Process:" blocks that some models (e.g. Qwen3) emit before the actual response.
|
||||||
|
|
||||||
**System Prompt:**
|
**System Prompt:**
|
||||||
|
|
||||||
@@ -182,11 +190,37 @@ STRICT RULES:
|
|||||||
5. Use a neutral, professional tone. Avoid hype or marketing language.
|
5. Use a neutral, professional tone. Avoid hype or marketing language.
|
||||||
6. Return ONLY the rewritten thesis text. No JSON, no markdown, no
|
6. Return ONLY the rewritten thesis text. No JSON, no markdown, no
|
||||||
commentary.
|
commentary.
|
||||||
|
7. Do NOT show your thinking process. Do NOT include any reasoning
|
||||||
|
steps. Output ONLY the final rewritten text.
|
||||||
```
|
```
|
||||||
|
|
||||||
**User Prompt Template** (built by `build_thesis_rewrite_prompt()` in `services/recommendation/thesis_llm.py`):
|
**User Prompt Template** (built by `build_thesis_rewrite_prompt()` in `services/recommendation/thesis_llm.py`):
|
||||||
- Includes the deterministic thesis between delimiters
|
- Includes the deterministic thesis between delimiters
|
||||||
- Includes trend context: ticker, window, direction, strength, confidence, contradiction score, top catalysts, top risks
|
- Includes trend context: ticker, window, direction, strength, confidence, contradiction score, top catalysts, top risks
|
||||||
|
- Appends `/no_think` suffix to suppress reasoning mode on models that support it (e.g. Qwen3)
|
||||||
|
- Ollama calls also set `"think": false` in the request payload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LLM Provider Abstraction
|
||||||
|
|
||||||
|
All three agents support both **Ollama** and **vLLM** as inference providers. The provider is determined by the `model_provider` field in the agent config (or active variant).
|
||||||
|
|
||||||
|
**Module:** `services/extractor/llm_factory.py`
|
||||||
|
|
||||||
|
The `build_llm_client()` factory function routes to the correct client:
|
||||||
|
|
||||||
|
| `model_provider` value | Client class | API endpoint |
|
||||||
|
|------------------------|-------------|--------------|
|
||||||
|
| `ollama` (default), `""`, `None` | `OllamaClient` (`services/extractor/client.py`) | `{OLLAMA_BASE_URL}/api/chat` |
|
||||||
|
| `vllm` | `VLLMClient` (`services/extractor/vllm_client.py`) | `{VLLM_BASE_URL}/v1/chat/completions` (OpenAI-compatible) |
|
||||||
|
| Unknown value | `OllamaClient` (with warning log) | Falls back to Ollama |
|
||||||
|
|
||||||
|
Both clients implement the `LLMClient` protocol (`services/shared/llm_protocol.py`), providing `call_llm()` and `close()` methods.
|
||||||
|
|
||||||
|
**Provider switching at runtime:** When a variant changes the `model_provider`, the extractor worker detects this during its periodic config refresh (every 100 jobs) and creates a new client instance. The old client is closed gracefully. A safety guard prevents switching to Ollama if `OLLAMA_BASE_URL` is empty.
|
||||||
|
|
||||||
|
**vLLM health check:** At startup, if the resolved provider is `vllm`, the extractor runs a health check against the vLLM endpoint. If it fails, the worker falls back to Ollama automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -202,8 +236,8 @@ Defined in migration `026_ai_agents.sql`. Stores the base configuration for each
|
|||||||
| `name` | `VARCHAR(100)` | — | Human-readable name (unique) |
|
| `name` | `VARCHAR(100)` | — | Human-readable name (unique) |
|
||||||
| `slug` | `VARCHAR(100)` | — | URL-safe identifier (unique), used by `AgentConfigResolver` |
|
| `slug` | `VARCHAR(100)` | — | URL-safe identifier (unique), used by `AgentConfigResolver` |
|
||||||
| `purpose` | `TEXT` | `''` | Description of what the agent does |
|
| `purpose` | `TEXT` | `''` | Description of what the agent does |
|
||||||
| `model_provider` | `VARCHAR(50)` | `'ollama'` | LLM provider |
|
| `model_provider` | `VARCHAR(50)` | `'ollama'` | LLM provider (`ollama` or `vllm`) |
|
||||||
| `model_name` | `VARCHAR(200)` | `'qwen3.5:9b'` | Model identifier |
|
| `model_name` | `VARCHAR(200)` | `'qwen3.5:9b-fast'` | Model identifier |
|
||||||
| `system_prompt` | `TEXT` | `''` | System prompt sent to the model |
|
| `system_prompt` | `TEXT` | `''` | System prompt sent to the model |
|
||||||
| `user_prompt_template` | `TEXT` | `''` | User prompt template (optional — code-defined templates take precedence) |
|
| `user_prompt_template` | `TEXT` | `''` | User prompt template (optional — code-defined templates take precedence) |
|
||||||
| `prompt_version` | `VARCHAR(100)` | `''` | Version tag for prompt tracking |
|
| `prompt_version` | `VARCHAR(100)` | `''` | Version tag for prompt tracking |
|
||||||
@@ -303,7 +337,14 @@ The `AgentConfigResolver` is the central mechanism for resolving runtime agent c
|
|||||||
COALESCE(v.model_name, a.model_name) AS model_name,
|
COALESCE(v.model_name, a.model_name) AS model_name,
|
||||||
COALESCE(v.system_prompt, a.system_prompt) AS system_prompt,
|
COALESCE(v.system_prompt, a.system_prompt) AS system_prompt,
|
||||||
COALESCE(v.user_prompt_template, a.user_prompt_template) AS user_prompt_template,
|
COALESCE(v.user_prompt_template, a.user_prompt_template) AS user_prompt_template,
|
||||||
-- ... all other fields ...
|
COALESCE(v.prompt_version, a.prompt_version) AS prompt_version,
|
||||||
|
COALESCE(v.temperature, a.temperature) AS temperature,
|
||||||
|
COALESCE(v.max_tokens, a.max_tokens) AS max_tokens,
|
||||||
|
COALESCE(v.context_window, 0) AS context_window,
|
||||||
|
COALESCE(v.input_token_limit, 0) AS input_token_limit,
|
||||||
|
COALESCE(v.token_budget, 0) AS token_budget,
|
||||||
|
COALESCE(v.timeout_seconds, a.timeout_seconds) AS timeout_seconds,
|
||||||
|
COALESCE(v.max_retries, a.max_retries) AS max_retries
|
||||||
FROM ai_agents a
|
FROM ai_agents a
|
||||||
LEFT JOIN agent_variants v
|
LEFT JOIN agent_variants v
|
||||||
ON v.agent_id = a.id AND v.is_active = TRUE
|
ON v.agent_id = a.id AND v.is_active = TRUE
|
||||||
@@ -361,7 +402,10 @@ resolver.invalidate() # Clear all entries
|
|||||||
|
|
||||||
### Config Refresh in Workers
|
### Config Refresh in Workers
|
||||||
|
|
||||||
The extractor and recommendation workers periodically re-resolve their agent config (every 100 jobs for the extractor, every 50 jobs for the recommendation worker). If the resolved model changes, the worker creates a new `OllamaClient` instance with the updated configuration.
|
The extractor and recommendation workers periodically re-resolve their agent config to pick up variant swaps and model changes:
|
||||||
|
|
||||||
|
- **Extractor worker** (`services/extractor/main.py`): Re-resolves both `document-extractor` and `event-classifier` configs every **100 jobs**. If the resolved model or provider changes, the worker creates a new LLM client instance via `build_llm_client()` and closes the old one. A safety guard prevents switching to Ollama if `OLLAMA_BASE_URL` is empty.
|
||||||
|
- **Recommendation worker** (`services/recommendation/main.py`): Re-resolves the `thesis-rewriter` config every **50 jobs**. If the model changes, a new `OllamaConfig` is built.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -373,7 +417,7 @@ Every agent invocation is logged to `agent_performance_log` with the `agent_id`
|
|||||||
|
|
||||||
- **Document extractor**: Logged in `services/extractor/main.py` after each extraction. Records success/failure, duration, confidence, retry count, token estimates.
|
- **Document extractor**: Logged in `services/extractor/main.py` after each extraction. Records success/failure, duration, confidence, retry count, token estimates.
|
||||||
- **Event classifier**: Logged in `services/extractor/event_classifier.py` after each classification. Same fields.
|
- **Event classifier**: Logged in `services/extractor/event_classifier.py` after each classification. Same fields.
|
||||||
- **Thesis rewriter**: Logged in `services/recommendation/thesis_llm.py` after each rewrite attempt. Confidence is always 0.0 (not applicable for rewrites).
|
- **Thesis rewriter**: Logged in `services/recommendation/thesis_llm.py` after each rewrite attempt. Confidence is always 0.0 (not applicable for rewrites). `document_id` is always NULL.
|
||||||
|
|
||||||
### Querying for Variant Comparison
|
### Querying for Variant Comparison
|
||||||
|
|
||||||
@@ -464,6 +508,8 @@ All agent endpoints are served by the Query API (`services/api/app.py`) under th
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All fields except `name` have defaults. The `slug` is auto-generated from `name` if not provided. The `model_name` defaults to `llama3.1:8b` for user-created agents.
|
||||||
|
|
||||||
**Update Agent Request Body** (all fields optional):
|
**Update Agent Request Body** (all fields optional):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -509,6 +555,30 @@ All agent endpoints are served by the Query API (`services/api/app.py`) under th
|
|||||||
| `PUT` | `/api/agents/{agent_id}/variants/{variant_id}` | Partial update a variant |
|
| `PUT` | `/api/agents/{agent_id}/variants/{variant_id}` | Partial update a variant |
|
||||||
| `DELETE` | `/api/agents/{agent_id}/variants/{variant_id}` | Delete a variant (returns 400 if active) |
|
| `DELETE` | `/api/agents/{agent_id}/variants/{variant_id}` | Delete a variant (returns 400 if active) |
|
||||||
|
|
||||||
|
**Create Variant Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"variant_name": "Llama 3.1 8B Test",
|
||||||
|
"variant_slug": "llama-3-1-8b-test",
|
||||||
|
"description": "Testing llama3.1:8b as an alternative",
|
||||||
|
"model_provider": "ollama",
|
||||||
|
"model_name": "llama3.1:8b",
|
||||||
|
"system_prompt": "",
|
||||||
|
"user_prompt_template": "",
|
||||||
|
"prompt_version": "",
|
||||||
|
"temperature": 0.0,
|
||||||
|
"max_tokens": 32768,
|
||||||
|
"context_window": 0,
|
||||||
|
"input_token_limit": 0,
|
||||||
|
"token_budget": 0,
|
||||||
|
"timeout_seconds": 120,
|
||||||
|
"max_retries": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required fields: `variant_name`, `model_name`. The `variant_slug` is auto-generated from `variant_name` if not provided.
|
||||||
|
|
||||||
### Clone Endpoints
|
### Clone Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
@@ -516,7 +586,7 @@ All agent endpoints are served by the Query API (`services/api/app.py`) under th
|
|||||||
| `POST` | `/api/agents/{agent_id}/clone` | Clone an agent's base config as a new variant |
|
| `POST` | `/api/agents/{agent_id}/clone` | Clone an agent's base config as a new variant |
|
||||||
| `POST` | `/api/agents/{agent_id}/variants/{variant_id}/clone` | Clone an existing variant as a new variant |
|
| `POST` | `/api/agents/{agent_id}/variants/{variant_id}/clone` | Clone an existing variant as a new variant |
|
||||||
|
|
||||||
Clone requests copy all configuration fields from the source, with optional overrides in the request body.
|
Clone requests copy all configuration fields from the source, with optional overrides in the request body. The `variant_name` field is required. All other fields default to the source's values if not provided.
|
||||||
|
|
||||||
### Activate / Deactivate
|
### Activate / Deactivate
|
||||||
|
|
||||||
@@ -525,6 +595,8 @@ Clone requests copy all configuration fields from the source, with optional over
|
|||||||
| `POST` | `/api/agents/{agent_id}/variants/{variant_id}/activate` | Set a variant as active (deactivates any other active variant in a single transaction) |
|
| `POST` | `/api/agents/{agent_id}/variants/{variant_id}/activate` | Set a variant as active (deactivates any other active variant in a single transaction) |
|
||||||
| `POST` | `/api/agents/{agent_id}/variants/deactivate` | Deactivate the currently active variant (agent falls back to base config) |
|
| `POST` | `/api/agents/{agent_id}/variants/deactivate` | Deactivate the currently active variant (agent falls back to base config) |
|
||||||
|
|
||||||
|
The activate endpoint uses a database transaction to atomically deactivate the current variant and activate the new one, ensuring exactly one active variant at all times.
|
||||||
|
|
||||||
### Per-Variant Performance
|
### Per-Variant Performance
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
@@ -532,6 +604,8 @@ Clone requests copy all configuration fields from the source, with optional over
|
|||||||
| `GET` | `/api/agents/{agent_id}/variants/{variant_id}/performance` | Aggregated metrics for a specific variant |
|
| `GET` | `/api/agents/{agent_id}/variants/{variant_id}/performance` | Aggregated metrics for a specific variant |
|
||||||
| `GET` | `/api/agents/{agent_id}/variants/{variant_id}/performance/history` | Hourly time-series for a specific variant |
|
| `GET` | `/api/agents/{agent_id}/variants/{variant_id}/performance/history` | Hourly time-series for a specific variant |
|
||||||
|
|
||||||
|
Both endpoints accept the same `hours` query parameter (default 24, max 720) and return the same response shape as the agent-level performance endpoints.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step-by-Step: Creating and Activating a Variant
|
## Step-by-Step: Creating and Activating a Variant
|
||||||
@@ -616,3 +690,20 @@ curl -s -X PUT \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then re-activate and compare again.
|
Then re-activate and compare again.
|
||||||
|
|
||||||
|
### 7. Switch to vLLM Provider
|
||||||
|
|
||||||
|
To test a variant using vLLM instead of Ollama:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://stonks-api.celestium.life/api/agents/$AGENT_ID/clone \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"variant_name": "vLLM Qwen3 Test",
|
||||||
|
"description": "Testing extraction with vLLM backend",
|
||||||
|
"model_provider": "vllm",
|
||||||
|
"model_name": "Qwen/Qwen3-8B"
|
||||||
|
}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
The extractor worker will detect the provider change during its next config refresh and build a `VLLMClient` instead of an `OllamaClient`. Ensure the `VLLM_BASE_URL` environment variable is set in the extractor deployment.
|
||||||
+184
-18
@@ -142,14 +142,35 @@ Trend projection for a specific trend window.
|
|||||||
### 1.5 Market Prices
|
### 1.5 Market Prices
|
||||||
|
|
||||||
#### `GET /api/market/prices/{ticker}`
|
#### `GET /api/market/prices/{ticker}`
|
||||||
Historical close prices from `market_snapshots`.
|
Historical OHLCV bars from `market_snapshots`, deduplicated by bar timestamp and ordered oldest-first. Also returns 90-day high/low range.
|
||||||
|
|
||||||
| Parameter | Type | Default | Constraints | Description |
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|-----------|------|---------|-------------|-------------|
|
|-----------|------|---------|-------------|-------------|
|
||||||
| `limit` | int | `30` | max `200` | Max bars returned |
|
| `limit` | int | `200` | max `500` | Max bars returned |
|
||||||
|
|
||||||
- **Path params:** `ticker` (auto-uppercased)
|
- **Path params:** `ticker` (auto-uppercased)
|
||||||
- **Response:** Array of OHLCV objects ordered oldest-first
|
- **Response:** `{ bars: [{ ticker, close, open, high, low, volume, bar_timestamp, captured_at }], range_90d: { low, high } }`
|
||||||
|
|
||||||
|
#### `POST /api/market/backfill/{ticker}`
|
||||||
|
Backfill daily OHLCV bars from Polygon for the last N days. Deduplicates by bar timestamp.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `days` | int | `90` | max `365` | Number of days to backfill |
|
||||||
|
|
||||||
|
- **Path params:** `ticker` (auto-uppercased)
|
||||||
|
- **Response:** `{ ticker, inserted, total_bars, days }`
|
||||||
|
- **Errors:** `503` — No market data API key configured
|
||||||
|
|
||||||
|
#### `POST /api/market/backfill-all`
|
||||||
|
Backfill daily bars for all active companies from Polygon.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `days` | int | `90` | max `365` | Number of days to backfill |
|
||||||
|
|
||||||
|
- **Response:** `{ total_inserted, tickers, details[] }` — each detail has `{ ticker, inserted }` or `{ ticker, inserted: 0, error }`
|
||||||
|
- **Errors:** `503` — No market data API key configured
|
||||||
|
|
||||||
### 1.6 Recommendations
|
### 1.6 Recommendations
|
||||||
|
|
||||||
@@ -224,8 +245,6 @@ Get audit events for any entity type and ID.
|
|||||||
|
|
||||||
- **Path params:** `entity_type` (string), `entity_id` (string)
|
- **Path params:** `entity_type` (string), `entity_id` (string)
|
||||||
- **Response:** Array of audit event objects
|
- **Response:** Array of audit event objects
|
||||||
- **Errors:** `404` — No audit events found
|
|
||||||
|
|
||||||
|
|
||||||
### 1.10 Admin: Source Health
|
### 1.10 Admin: Source Health
|
||||||
|
|
||||||
@@ -331,6 +350,8 @@ Approve or reject a pending operator approval request.
|
|||||||
#### `GET /api/admin/trading/lockouts`
|
#### `GET /api/admin/trading/lockouts`
|
||||||
List active symbol lockouts (news-shock, cooldown, manual).
|
List active symbol lockouts (news-shock, cooldown, manual).
|
||||||
|
|
||||||
|
- **Response:** Array of lockout objects
|
||||||
|
|
||||||
#### `POST /api/admin/trading/lockouts`
|
#### `POST /api/admin/trading/lockouts`
|
||||||
Create a manual symbol lockout.
|
Create a manual symbol lockout.
|
||||||
|
|
||||||
@@ -353,7 +374,6 @@ Update operator approval settings.
|
|||||||
- **Body:** `{ auto_approve_paper?: bool, require_approval_for_live?: bool, approval_timeout_minutes?: int }`
|
- **Body:** `{ auto_approve_paper?: bool, require_approval_for_live?: bool, approval_timeout_minutes?: int }`
|
||||||
- **Response:** Updated approval settings
|
- **Response:** Updated approval settings
|
||||||
|
|
||||||
|
|
||||||
### 1.13 Operational Dashboard
|
### 1.13 Operational Dashboard
|
||||||
|
|
||||||
#### `GET /api/ops/ingestion/throughput`
|
#### `GET /api/ops/ingestion/throughput`
|
||||||
@@ -450,7 +470,7 @@ Trino catalog/schema/table/column metadata for the schema browser.
|
|||||||
#### `GET /api/analytics/pg-schema`
|
#### `GET /api/analytics/pg-schema`
|
||||||
PostgreSQL table/column metadata with primary keys, foreign keys, and row estimates.
|
PostgreSQL table/column metadata with primary keys, foreign keys, and row estimates.
|
||||||
|
|
||||||
- **Response:** `{ catalog: "postgresql", schema: "public", tables[] }`
|
- **Response:** `{ catalog: "postgresql", schema: "public", tables[{ name, row_estimate, columns[{ name, type, nullable, primary_key?, references?, has_default? }] }] }`
|
||||||
|
|
||||||
#### `POST /api/analytics/pg-query`
|
#### `POST /api/analytics/pg-query`
|
||||||
Run read-only SQL against PostgreSQL directly. Only SELECT statements allowed.
|
Run read-only SQL against PostgreSQL directly. Only SELECT statements allowed.
|
||||||
@@ -462,17 +482,19 @@ Run read-only SQL against PostgreSQL directly. Only SELECT statements allowed.
|
|||||||
#### `GET /api/analytics/saved-queries`
|
#### `GET /api/analytics/saved-queries`
|
||||||
List all saved queries.
|
List all saved queries.
|
||||||
|
|
||||||
|
- **Response:** Array of `{ id, name, description, sql_text, created_by, created_at, updated_at }`
|
||||||
|
|
||||||
#### `POST /api/analytics/saved-queries` (201)
|
#### `POST /api/analytics/saved-queries` (201)
|
||||||
Save a new query.
|
Save a new query.
|
||||||
|
|
||||||
- **Body:** `{ name: string, description?: string, sql_text: string }`
|
- **Body:** `{ name: string, description?: string, sql_text: string }`
|
||||||
|
- **Response:** `{ id, name, description, sql_text, created_by, created_at }`
|
||||||
|
|
||||||
#### `DELETE /api/analytics/saved-queries/{query_id}`
|
#### `DELETE /api/analytics/saved-queries/{query_id}`
|
||||||
Delete a saved query.
|
Delete a saved query.
|
||||||
|
|
||||||
- **Errors:** `404` — Query not found
|
- **Errors:** `404` — Query not found
|
||||||
|
|
||||||
|
|
||||||
### 1.16 Macro Signal Layer
|
### 1.16 Macro Signal Layer
|
||||||
|
|
||||||
#### `GET /api/admin/macro/status`
|
#### `GET /api/admin/macro/status`
|
||||||
@@ -501,9 +523,13 @@ List recent global events with filtering.
|
|||||||
| `limit` | int | `50` | max `200` | Page size |
|
| `limit` | int | `50` | max `200` | Page size |
|
||||||
| `offset` | int | `0` | — | Pagination offset |
|
| `offset` | int | `0` | — | Pagination offset |
|
||||||
|
|
||||||
|
- **Response:** Array of global event objects with `id`, `event_types`, `severity`, `affected_regions`, `affected_sectors`, `affected_commodities`, `summary`, `key_facts`, `estimated_duration`, `confidence`, `source_document_id`, `created_at`
|
||||||
|
|
||||||
#### `GET /api/macro/events/{event_id}`
|
#### `GET /api/macro/events/{event_id}`
|
||||||
Event detail with affected companies and macro impact scores.
|
Event detail with affected companies and macro impact scores.
|
||||||
|
|
||||||
|
- **Path params:** `event_id` (UUID string)
|
||||||
|
- **Response:** Global event object + `impacts[]` (each with `company_id`, `ticker`, `macro_impact_score`, `impact_direction`, `contributing_factors`, `confidence`, `legal_name`, `sector`)
|
||||||
- **Errors:** `404` — Global event not found
|
- **Errors:** `404` — Global event not found
|
||||||
|
|
||||||
#### `GET /api/macro/impacts/{ticker}`
|
#### `GET /api/macro/impacts/{ticker}`
|
||||||
@@ -515,7 +541,8 @@ Macro impacts and exposure profile for a specific company.
|
|||||||
| `limit` | int | `50` | max `200` | Page size |
|
| `limit` | int | `50` | max `200` | Page size |
|
||||||
| `offset` | int | `0` | — | Pagination offset |
|
| `offset` | int | `0` | — | Pagination offset |
|
||||||
|
|
||||||
- **Response:** `{ exposure_profile, impacts[] }`
|
- **Path params:** `ticker` (auto-uppercased)
|
||||||
|
- **Response:** `{ exposure_profile, impacts[] }` — each impact includes `event_summary`, `event_severity`, `event_types`, `affected_regions`
|
||||||
|
|
||||||
### 1.18 Competitive Signal Layer
|
### 1.18 Competitive Signal Layer
|
||||||
|
|
||||||
@@ -540,6 +567,7 @@ Historical patterns for a company.
|
|||||||
| `catalyst_type` | string | — | Filter by catalyst type |
|
| `catalyst_type` | string | — | Filter by catalyst type |
|
||||||
| `time_horizon` | string | — | Filter by time horizon |
|
| `time_horizon` | string | — | Filter by time horizon |
|
||||||
|
|
||||||
|
- **Path params:** `ticker` (string)
|
||||||
- **Response:** `{ ticker, patterns[], count }`
|
- **Response:** `{ ticker, patterns[], count }`
|
||||||
|
|
||||||
#### `GET /api/patterns/{ticker}/competitors`
|
#### `GET /api/patterns/{ticker}/competitors`
|
||||||
@@ -555,6 +583,7 @@ Cross-company patterns showing how this company's catalysts affected competitors
|
|||||||
#### `GET /api/patterns/{ticker}/competitive-signals`
|
#### `GET /api/patterns/{ticker}/competitive-signals`
|
||||||
Recent competitive signals targeting this company (limit 100).
|
Recent competitive signals targeting this company (limit 100).
|
||||||
|
|
||||||
|
- **Path params:** `ticker` (string)
|
||||||
- **Response:** `{ ticker, competitive_signals[], count }`
|
- **Response:** `{ ticker, competitive_signals[], count }`
|
||||||
|
|
||||||
#### `GET /api/patterns/{ticker}/decisions`
|
#### `GET /api/patterns/{ticker}/decisions`
|
||||||
@@ -564,9 +593,9 @@ Major corporate decision history with trend outcomes and pattern statistics.
|
|||||||
|-----------|------|---------|-------------|
|
|-----------|------|---------|-------------|
|
||||||
| `time_horizon` | string | — | Filter by time horizon |
|
| `time_horizon` | string | — | Filter by time horizon |
|
||||||
|
|
||||||
|
- **Path params:** `ticker` (string)
|
||||||
- **Response:** `{ ticker, decisions[], count }` — each decision includes `pattern_statistics[]`
|
- **Response:** `{ ticker, decisions[], count }` — each decision includes `pattern_statistics[]`
|
||||||
|
|
||||||
|
|
||||||
### 1.20 AI Agents
|
### 1.20 AI Agents
|
||||||
|
|
||||||
#### `GET /api/agents`
|
#### `GET /api/agents`
|
||||||
@@ -576,9 +605,12 @@ List all AI agent configurations.
|
|||||||
|-----------|------|---------|-------------|
|
|-----------|------|---------|-------------|
|
||||||
| `active_only` | bool | `false` | Only show active agents |
|
| `active_only` | bool | `false` | Only show active agents |
|
||||||
|
|
||||||
|
- **Response:** Array of agent objects with `id`, `name`, `slug`, `purpose`, `model_provider`, `model_name`, `system_prompt`, `user_prompt_template`, `prompt_version`, `schema_version`, `temperature`, `max_tokens`, `timeout_seconds`, `max_retries`, `active`, `source`, `created_at`, `updated_at`
|
||||||
|
|
||||||
#### `GET /api/agents/{agent_id}`
|
#### `GET /api/agents/{agent_id}`
|
||||||
Get a single agent configuration.
|
Get a single agent configuration.
|
||||||
|
|
||||||
|
- **Path params:** `agent_id` (UUID string)
|
||||||
- **Errors:** `404` — Agent not found
|
- **Errors:** `404` — Agent not found
|
||||||
|
|
||||||
#### `POST /api/agents` (201)
|
#### `POST /api/agents` (201)
|
||||||
@@ -603,9 +635,9 @@ Create a new user-defined agent.
|
|||||||
| `max_retries` | int | `2` | Max retry attempts |
|
| `max_retries` | int | `2` | Max retry attempts |
|
||||||
|
|
||||||
#### `PUT /api/agents/{agent_id}`
|
#### `PUT /api/agents/{agent_id}`
|
||||||
Update an agent configuration. Partial updates supported.
|
Update an agent configuration. Partial updates supported — only provided fields are changed.
|
||||||
|
|
||||||
- **Body:** `AgentUpdateBody` — all fields optional (same fields as create)
|
- **Body:** `AgentUpdateBody` — all fields optional (same fields as create plus `active`)
|
||||||
- **Errors:** `400` — No fields to update; `404` — Agent not found
|
- **Errors:** `400` — No fields to update; `404` — Agent not found
|
||||||
|
|
||||||
#### `DELETE /api/agents/{agent_id}`
|
#### `DELETE /api/agents/{agent_id}`
|
||||||
@@ -636,6 +668,8 @@ Hourly performance time-series for an agent.
|
|||||||
#### `GET /api/agents/{agent_id}/variants`
|
#### `GET /api/agents/{agent_id}/variants`
|
||||||
List all variants for an agent, ordered by `created_at` ascending.
|
List all variants for an agent, ordered by `created_at` ascending.
|
||||||
|
|
||||||
|
- **Response:** Array of variant objects with `id`, `agent_id`, `variant_name`, `variant_slug`, `description`, `model_provider`, `model_name`, `system_prompt`, `user_prompt_template`, `prompt_version`, `temperature`, `max_tokens`, `context_window`, `input_token_limit`, `token_budget`, `timeout_seconds`, `max_retries`, `is_active`, `created_at`, `updated_at`
|
||||||
|
|
||||||
#### `GET /api/agents/{agent_id}/variants/{variant_id}`
|
#### `GET /api/agents/{agent_id}/variants/{variant_id}`
|
||||||
Get a single variant.
|
Get a single variant.
|
||||||
|
|
||||||
@@ -680,13 +714,13 @@ Delete a variant. Cannot delete active variants.
|
|||||||
#### `POST /api/agents/{agent_id}/clone` (201)
|
#### `POST /api/agents/{agent_id}/clone` (201)
|
||||||
Clone an agent's configuration as a new variant with optional overrides.
|
Clone an agent's configuration as a new variant with optional overrides.
|
||||||
|
|
||||||
- **Body:** `VariantCloneBody { variant_name, variant_slug?, ...optional overrides }`
|
- **Body:** `VariantCloneBody { variant_name, variant_slug?, description?, model_provider?, model_name?, system_prompt?, user_prompt_template?, prompt_version?, temperature?, max_tokens?, context_window?, input_token_limit?, token_budget?, timeout_seconds?, max_retries? }`
|
||||||
- **Errors:** `404` — Agent not found; `409` — Duplicate slug
|
- **Errors:** `404` — Agent not found; `409` — Duplicate slug
|
||||||
|
|
||||||
#### `POST /api/agents/{agent_id}/variants/{variant_id}/clone` (201)
|
#### `POST /api/agents/{agent_id}/variants/{variant_id}/clone` (201)
|
||||||
Clone an existing variant as a new variant with optional overrides.
|
Clone an existing variant as a new variant with optional overrides.
|
||||||
|
|
||||||
- **Body:** `VariantCloneBody`
|
- **Body:** `VariantCloneBody` (same as above)
|
||||||
- **Errors:** `404` — Source variant not found; `409` — Duplicate slug
|
- **Errors:** `404` — Source variant not found; `409` — Duplicate slug
|
||||||
|
|
||||||
#### `POST /api/agents/{agent_id}/variants/{variant_id}/activate`
|
#### `POST /api/agents/{agent_id}/variants/{variant_id}/activate`
|
||||||
@@ -697,6 +731,8 @@ Set a variant as the active variant for its agent. Deactivates any currently act
|
|||||||
#### `POST /api/agents/{agent_id}/variants/deactivate`
|
#### `POST /api/agents/{agent_id}/variants/deactivate`
|
||||||
Deactivate the currently active variant. Agent falls back to base configuration.
|
Deactivate the currently active variant. Agent falls back to base configuration.
|
||||||
|
|
||||||
|
- **Response:** `{ deactivated: true }`
|
||||||
|
|
||||||
#### `GET /api/agents/{agent_id}/variants/{variant_id}/performance`
|
#### `GET /api/agents/{agent_id}/variants/{variant_id}/performance`
|
||||||
Aggregated performance metrics for a specific variant.
|
Aggregated performance metrics for a specific variant.
|
||||||
|
|
||||||
@@ -704,6 +740,8 @@ Aggregated performance metrics for a specific variant.
|
|||||||
|-----------|------|---------|-------------|-------------|
|
|-----------|------|---------|-------------|-------------|
|
||||||
| `hours` | int | `24` | max `720` | Time window |
|
| `hours` | int | `24` | max `720` | Time window |
|
||||||
|
|
||||||
|
- **Response:** Same shape as agent performance (invocations, successes, failures, durations, confidence, tokens, success_rate)
|
||||||
|
|
||||||
#### `GET /api/agents/{agent_id}/variants/{variant_id}/performance/history`
|
#### `GET /api/agents/{agent_id}/variants/{variant_id}/performance/history`
|
||||||
Hourly performance time-series for a specific variant.
|
Hourly performance time-series for a specific variant.
|
||||||
|
|
||||||
@@ -711,6 +749,108 @@ Hourly performance time-series for a specific variant.
|
|||||||
|-----------|------|---------|-------------|-------------|
|
|-----------|------|---------|-------------|-------------|
|
||||||
| `hours` | int | `24` | max `720` | Time window |
|
| `hours` | int | `24` | max `720` | Time window |
|
||||||
|
|
||||||
|
- **Response:** Array of `{ hour, invocations, successes, avg_duration_ms, avg_confidence }`
|
||||||
|
|
||||||
|
### 1.22 Model Validation
|
||||||
|
|
||||||
|
#### `GET /api/validation/summary`
|
||||||
|
Latest model metric snapshot plus quality gate status.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `lookback` | string | `"30d"` | `7d`, `30d`, `90d`, `all` | Lookback window |
|
||||||
|
| `horizon` | string | `"7d"` | `1h`, `6h`, `1d`, `7d`, `30d` | Prediction horizon |
|
||||||
|
|
||||||
|
- **Response:** `{ snapshot: { id, generated_at, lookback_window, horizon, prediction_count, win_rate, directional_accuracy, information_coefficient, rank_information_coefficient, avg_return, avg_excess_return_vs_spy, avg_excess_return_vs_sector, calibration_error, brier_score, buy_win_rate, sell_win_rate, hold_win_rate, metadata }, gate_status }`
|
||||||
|
- **Errors:** `400` — Invalid lookback or horizon value
|
||||||
|
|
||||||
|
#### `GET /api/validation/calibration`
|
||||||
|
Calibration table with confidence buckets showing predicted vs observed win rates.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `lookback` | string | `"30d"` | `7d`, `30d`, `90d`, `all` | Lookback window |
|
||||||
|
| `horizon` | string | `"7d"` | `1h`, `6h`, `1d`, `7d`, `30d` | Prediction horizon |
|
||||||
|
|
||||||
|
- **Response:** `{ buckets: [{ bucket_low, bucket_high, avg_confidence, observed_win_rate, prediction_count, miscalibrated }], lookback, horizon }`
|
||||||
|
- Buckets: 0.50–0.60, 0.60–0.70, 0.70–0.80, 0.80–0.90, 0.90–1.00
|
||||||
|
- `miscalibrated` is `true` when `|avg_confidence - observed_win_rate| > 0.15`
|
||||||
|
- **Errors:** `400` — Invalid lookback or horizon value
|
||||||
|
|
||||||
|
#### `GET /api/validation/ic-by-horizon`
|
||||||
|
Information Coefficient and Rank IC per prediction horizon.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `lookback` | string | `"30d"` | `7d`, `30d`, `90d`, `all` | Lookback window |
|
||||||
|
|
||||||
|
- **Response:** `{ horizons: [{ horizon, information_coefficient, rank_information_coefficient, prediction_count, generated_at }], lookback }`
|
||||||
|
- Horizons ordered: `1h`, `6h`, `1d`, `7d`, `30d`
|
||||||
|
- **Errors:** `400` — Invalid lookback value
|
||||||
|
|
||||||
|
#### `GET /api/validation/gate-status`
|
||||||
|
Quality gate evaluation detail from `risk_configs` where `name = 'model_quality_gate'`.
|
||||||
|
|
||||||
|
- **Response:** `{ gate_status, updated_at }` or `{ gate_status: null, message: "No gate evaluation found..." }`
|
||||||
|
|
||||||
|
### 1.23 Attribution
|
||||||
|
|
||||||
|
#### `GET /api/validation/attribution/sources`
|
||||||
|
Per-source performance metrics: win rate, IC, average return, duplicate rate.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `lookback` | string | `"30d"` | `7d`, `30d`, `90d`, `all` | Lookback window |
|
||||||
|
| `horizon` | string | `"7d"` | `1h`, `6h`, `1d`, `7d`, `30d` | Prediction horizon |
|
||||||
|
|
||||||
|
- **Response:** `{ sources[], lookback, horizon }`
|
||||||
|
- **Errors:** `400` — Invalid lookback or horizon; `500` — Computation failed
|
||||||
|
|
||||||
|
#### `GET /api/validation/attribution/catalysts`
|
||||||
|
Per-catalyst-type performance metrics: win rate, IC, average return.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `lookback` | string | `"30d"` | `7d`, `30d`, `90d`, `all` | Lookback window |
|
||||||
|
| `horizon` | string | `"7d"` | `1h`, `6h`, `1d`, `7d`, `30d` | Prediction horizon |
|
||||||
|
|
||||||
|
- **Response:** `{ catalysts[], lookback, horizon }`
|
||||||
|
- **Errors:** `400` — Invalid lookback or horizon; `500` — Computation failed
|
||||||
|
|
||||||
|
#### `GET /api/validation/attribution/layers`
|
||||||
|
Per-signal-layer (company, macro, competitive) performance metrics.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `lookback` | string | `"30d"` | `7d`, `30d`, `90d`, `all` | Lookback window |
|
||||||
|
| `horizon` | string | `"7d"` | `1h`, `6h`, `1d`, `7d`, `30d` | Prediction horizon |
|
||||||
|
|
||||||
|
- **Response:** `{ layers[], lookback, horizon }` — each layer has `avg_contribution_pct`, `dominant_win_rate`, `dominant_ic`
|
||||||
|
- **Errors:** `400` — Invalid lookback or horizon; `500` — Computation failed
|
||||||
|
|
||||||
|
### 1.24 Trading Reports
|
||||||
|
|
||||||
|
#### `GET /api/reports`
|
||||||
|
Paginated list of trading reports with optional filtering.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|
|-----------|------|---------|-------------|-------------|
|
||||||
|
| `report_type` | string | — | `daily` or `weekly` | Filter by report type |
|
||||||
|
| `start_date` | string | — | ISO date (YYYY-MM-DD) | Filter `period_start >= this` |
|
||||||
|
| `end_date` | string | — | ISO date (YYYY-MM-DD) | Filter `period_end <= this` |
|
||||||
|
| `limit` | int | `20` | max `100` | Page size |
|
||||||
|
| `offset` | int | `0` | min `0` | Pagination offset |
|
||||||
|
|
||||||
|
- **Response:** Array of `{ id, report_type, period_start, period_end, validation_status, generated_at }`
|
||||||
|
- **Errors:** `400` — Invalid `report_type` or date format
|
||||||
|
|
||||||
|
#### `GET /api/reports/{report_id}`
|
||||||
|
Fetch a single report including full `report_data` JSONB.
|
||||||
|
|
||||||
|
- **Path params:** `report_id` (UUID string)
|
||||||
|
- **Response:** `{ id, report_type, period_start, period_end, report_data, validation_status, generated_at, created_at }`
|
||||||
|
- **Errors:** `404` — Report not found
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Symbol Registry API
|
## 2. Symbol Registry API
|
||||||
@@ -756,6 +896,7 @@ List tracked companies.
|
|||||||
#### `GET /companies/{company_id}`
|
#### `GET /companies/{company_id}`
|
||||||
Get a single company.
|
Get a single company.
|
||||||
|
|
||||||
|
- **Path params:** `company_id` (UUID string)
|
||||||
- **Errors:** `404` — Company not found
|
- **Errors:** `404` — Company not found
|
||||||
|
|
||||||
#### `PUT /companies/{company_id}`
|
#### `PUT /companies/{company_id}`
|
||||||
@@ -783,14 +924,18 @@ List aliases for a company.
|
|||||||
Create a new watchlist.
|
Create a new watchlist.
|
||||||
|
|
||||||
- **Body:** `{ name: string, description?: string }`
|
- **Body:** `{ name: string, description?: string }`
|
||||||
|
- **Response:** `{ id, name, description, active }`
|
||||||
- **Errors:** `409` — Watchlist name already exists
|
- **Errors:** `409` — Watchlist name already exists
|
||||||
|
|
||||||
#### `GET /watchlists`
|
#### `GET /watchlists`
|
||||||
List all watchlists.
|
List all watchlists.
|
||||||
|
|
||||||
|
- **Response:** Array of `{ id, name, description, active }`
|
||||||
|
|
||||||
#### `POST /watchlists/{watchlist_id}/members/{company_id}` (201)
|
#### `POST /watchlists/{watchlist_id}/members/{company_id}` (201)
|
||||||
Add a company to a watchlist.
|
Add a company to a watchlist.
|
||||||
|
|
||||||
|
- **Response:** `{ status: "added" }`
|
||||||
- **Errors:** `409` — Already a member; `404` — Watchlist or company not found
|
- **Errors:** `409` — Already a member; `404` — Watchlist or company not found
|
||||||
|
|
||||||
#### `GET /watchlists/{watchlist_id}/members`
|
#### `GET /watchlists/{watchlist_id}/members`
|
||||||
@@ -814,11 +959,14 @@ Add a data source for a company.
|
|||||||
| `retention_days` | int | `365` | — | Data retention period |
|
| `retention_days` | int | `365` | — | Data retention period |
|
||||||
| `access_policy` | string | `"internal"` | `internal`, `public`, `restricted` | Access policy |
|
| `access_policy` | string | `"internal"` | `internal`, `public`, `restricted` | Access policy |
|
||||||
|
|
||||||
|
- **Response:** `{ id, source_type, source_name, credibility_score, active }`
|
||||||
- **Errors:** `404` — Company not found; `422` — Invalid source_type or access_policy
|
- **Errors:** `404` — Company not found; `422` — Invalid source_type or access_policy
|
||||||
|
|
||||||
#### `GET /companies/{company_id}/sources`
|
#### `GET /companies/{company_id}/sources`
|
||||||
List sources for a company.
|
List sources for a company.
|
||||||
|
|
||||||
|
- **Response:** Array of `{ id, source_type, source_name, config, credibility_score, retention_days, access_policy, active }`
|
||||||
|
|
||||||
### 2.6 Exposure Profiles
|
### 2.6 Exposure Profiles
|
||||||
|
|
||||||
#### `GET /companies/{company_id}/exposure`
|
#### `GET /companies/{company_id}/exposure`
|
||||||
@@ -848,6 +996,8 @@ Create or update an exposure profile. Archives the previous active version.
|
|||||||
#### `GET /companies/{company_id}/exposure/history`
|
#### `GET /companies/{company_id}/exposure/history`
|
||||||
Get all exposure profile versions for a company, ordered by version descending.
|
Get all exposure profile versions for a company, ordered by version descending.
|
||||||
|
|
||||||
|
- **Response:** Array of `ExposureProfileResponse`
|
||||||
|
|
||||||
### 2.7 Competitor Relationships
|
### 2.7 Competitor Relationships
|
||||||
|
|
||||||
#### `POST /companies/{company_id}/competitors` (201)
|
#### `POST /companies/{company_id}/competitors` (201)
|
||||||
@@ -863,10 +1013,11 @@ Create a competitor relationship. Records an audit event.
|
|||||||
| `bidirectional` | bool | `true` | — | Bidirectional relationship |
|
| `bidirectional` | bool | `true` | — | Bidirectional relationship |
|
||||||
| `source` | string | `"manual"` | `manual`, `inferred` | Data source |
|
| `source` | string | `"manual"` | `manual`, `inferred` | Data source |
|
||||||
|
|
||||||
|
- **Response:** `CompetitorRelationship { id, company_a_id, company_b_id, relationship_type, strength, bidirectional, source, active, created_at, updated_at }`
|
||||||
- **Errors:** `400` — Self-reference; `404` — Company not found; `409` — Relationship already exists
|
- **Errors:** `400` — Self-reference; `404` — Company not found; `409` — Relationship already exists
|
||||||
|
|
||||||
#### `GET /companies/{company_id}/competitors`
|
#### `GET /companies/{company_id}/competitors`
|
||||||
List active competitor relationships, enriched with ticker and legal_name of the other company.
|
List active competitor relationships, enriched with `ticker` and `legal_name` of the other company. Ordered by strength descending.
|
||||||
|
|
||||||
- **Errors:** `404` — Company not found
|
- **Errors:** `404` — Company not found
|
||||||
|
|
||||||
@@ -879,6 +1030,7 @@ Update a competitor relationship. Records an audit event with previous state.
|
|||||||
#### `DELETE /companies/{company_id}/competitors/{relationship_id}`
|
#### `DELETE /companies/{company_id}/competitors/{relationship_id}`
|
||||||
Soft-delete a competitor relationship (sets `active=false`). Records an audit event.
|
Soft-delete a competitor relationship (sets `active=false`). Records an audit event.
|
||||||
|
|
||||||
|
- **Response:** `{ status: "deleted", id }`
|
||||||
- **Errors:** `404` — Active relationship not found
|
- **Errors:** `404` — Active relationship not found
|
||||||
|
|
||||||
### 2.8 Competitor Inference
|
### 2.8 Competitor Inference
|
||||||
@@ -923,7 +1075,7 @@ Diagnostic endpoint showing engine internals for troubleshooting.
|
|||||||
#### `GET /api/trading/status`
|
#### `GET /api/trading/status`
|
||||||
Return current engine state.
|
Return current engine state.
|
||||||
|
|
||||||
- **Response:** `{ enabled, paused, risk_tier, circuit_breaker_status, active_pool, reserve_pool, portfolio_heat, open_positions, last_decision_at }`
|
- **Response:** `{ enabled, paused, risk_tier, circuit_breaker_status, active_pool, reserve_pool, portfolio_heat, open_positions, open_position_count, max_open_positions, absolute_position_cap, last_decision_at }`
|
||||||
- **Errors:** `503` — Engine not initialised
|
- **Errors:** `503` — Engine not initialised
|
||||||
|
|
||||||
#### `PUT /api/trading/config`
|
#### `PUT /api/trading/config`
|
||||||
@@ -960,7 +1112,13 @@ Resume the trading engine.
|
|||||||
#### `POST /api/trading/reset`
|
#### `POST /api/trading/reset`
|
||||||
Full paper trading reset: liquidate broker positions, cancel orders, clear trading state, reset capital.
|
Full paper trading reset: liquidate broker positions, cancel orders, clear trading state, reset capital.
|
||||||
|
|
||||||
- **Body:** `{ initial_capital?: float (default 0.0) }` — if 0, uses broker balance or defaults to 100,000
|
- **Body:** `CapitalRequest`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `initial_capital` | float | `0.0` | If 0, uses broker balance or defaults to 100,000 |
|
||||||
|
| `reserve_pct` | float | `null` | Reserve pool percentage (0–1). If null, uses engine config `reserve_siphon_pct` |
|
||||||
|
|
||||||
- **Response:** `{ reset: true, initial_capital, active_pool, reserve_pool, broker: { orders_cancelled, positions_closed, portfolio_value, cash, buying_power } }`
|
- **Response:** `{ reset: true, initial_capital, active_pool, reserve_pool, broker: { orders_cancelled, positions_closed, portfolio_value, cash, buying_power } }`
|
||||||
- **Errors:** `503` — Engine not initialised; `500` — Database reset failed
|
- **Errors:** `503` — Engine not initialised; `500` — Database reset failed
|
||||||
|
|
||||||
@@ -977,6 +1135,8 @@ Return recent trading decisions from the database.
|
|||||||
| `limit` | int | `50` | max `200` | Page size |
|
| `limit` | int | `50` | max `200` | Page size |
|
||||||
| `offset` | int | `0` | — | Pagination offset |
|
| `offset` | int | `0` | — | Pagination offset |
|
||||||
|
|
||||||
|
- **Response:** Array of `{ id, recommendation_id, decision, skip_reason, ticker, computed_position_size, computed_share_quantity, risk_tier_at_decision, portfolio_heat_at_decision, active_pool_at_decision, reserve_pool_at_decision, circuit_breaker_status, is_micro_trade, created_at }`
|
||||||
|
|
||||||
### 3.5 Performance Metrics
|
### 3.5 Performance Metrics
|
||||||
|
|
||||||
#### `GET /api/trading/metrics`
|
#### `GET /api/trading/metrics`
|
||||||
@@ -992,6 +1152,8 @@ Return historical daily portfolio snapshots.
|
|||||||
|-----------|------|---------|-------------|-------------|
|
|-----------|------|---------|-------------|-------------|
|
||||||
| `limit` | int | `30` | max `365` | Max snapshots |
|
| `limit` | int | `30` | max `365` | Max snapshots |
|
||||||
|
|
||||||
|
- **Response:** Array of `{ id, snapshot_date, portfolio_value, active_pool, reserve_pool, daily_return, cumulative_return, unrealized_pnl, realized_pnl, win_count, loss_count, win_rate, sharpe_ratio, max_drawdown, current_drawdown_pct, portfolio_heat, risk_tier, created_at }`
|
||||||
|
|
||||||
### 3.6 Backtesting
|
### 3.6 Backtesting
|
||||||
|
|
||||||
#### `POST /api/trading/backtest`
|
#### `POST /api/trading/backtest`
|
||||||
@@ -1012,6 +1174,7 @@ Launch a backtest run asynchronously.
|
|||||||
#### `GET /api/trading/backtest/{backtest_id}`
|
#### `GET /api/trading/backtest/{backtest_id}`
|
||||||
Retrieve backtest results.
|
Retrieve backtest results.
|
||||||
|
|
||||||
|
- **Path params:** `backtest_id` (UUID string)
|
||||||
- **Response:** `{ id, start_date, end_date, initial_capital, risk_tier, config, total_return, sharpe_ratio, max_drawdown, win_rate, profit_factor, trade_count, equity_curve[], trades[], status, completed_at, created_at }`
|
- **Response:** `{ id, start_date, end_date, initial_capital, risk_tier, config, total_return, sharpe_ratio, max_drawdown, win_rate, profit_factor, trade_count, equity_curve[], trades[], status, completed_at, created_at }`
|
||||||
- Status values: `running`, `completed`, `not_found`, `pending`
|
- Status values: `running`, `completed`, `not_found`, `pending`
|
||||||
|
|
||||||
@@ -1037,10 +1200,11 @@ Update notification preferences.
|
|||||||
|
|
||||||
All fields optional.
|
All fields optional.
|
||||||
|
|
||||||
|
- **Response:** `{ updated: { ...changed fields } }`
|
||||||
- **Errors:** `503` — Engine not initialised
|
- **Errors:** `503` — Engine not initialised
|
||||||
|
|
||||||
#### `GET /api/trading/notifications/history`
|
#### `GET /api/trading/notifications/history`
|
||||||
Return recent notifications.
|
Return recent notifications (placeholder — currently returns empty array).
|
||||||
|
|
||||||
| Parameter | Type | Default | Constraints | Description |
|
| Parameter | Type | Default | Constraints | Description |
|
||||||
|-----------|------|---------|-------------|-------------|
|
|-----------|------|---------|-------------|-------------|
|
||||||
@@ -1116,6 +1280,8 @@ List pending approval requests.
|
|||||||
#### `GET /approvals/{approval_id}`
|
#### `GET /approvals/{approval_id}`
|
||||||
Get a single approval request.
|
Get a single approval request.
|
||||||
|
|
||||||
|
- **Path params:** `approval_id` (UUID string)
|
||||||
|
- **Response:** Approval request object
|
||||||
- **Errors:** `404` — Approval not found; `503` — Database not ready
|
- **Errors:** `404` — Approval not found; `503` — Database not ready
|
||||||
|
|
||||||
#### `POST /approvals/{approval_id}/review`
|
#### `POST /approvals/{approval_id}/review`
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
%% ── Scheduler ─────────────────────────────────────────────────
|
%% ── Scheduler ─────────────────────────────────────────────────
|
||||||
scheduler["<b>Scheduler</b><br/><i>services.scheduler.app</i><br/>Cadence polling, rate limiting,<br/>backoff & stale recovery"]
|
scheduler["<b>Scheduler</b><br/><i>services.scheduler.app</i><br/>Cadence polling, rate limiting,<br/>backoff, stale recovery,<br/>periodic aggregation,<br/>report scheduling"]
|
||||||
|
|
||||||
sources -.->|"API polling<br/>on cadence"| scheduler
|
sources -.->|"API polling<br/>on cadence"| scheduler
|
||||||
|
|
||||||
%% ── Ingestion Queue ───────────────────────────────────────────
|
%% ── Ingestion Queue ───────────────────────────────────────────
|
||||||
q_ingestion[["stonks:queue:ingestion"]]
|
q_ingestion[["stonks:queue:ingestion"]]
|
||||||
scheduler -->|"rpush job"| q_ingestion
|
scheduler -->|"rpush job<br/>(company, macro,<br/>global market)"| q_ingestion
|
||||||
|
|
||||||
%% ── Ingestion Worker ──────────────────────────────────────────
|
%% ── Ingestion Worker ──────────────────────────────────────────
|
||||||
ingestion["<b>Ingestion</b><br/><i>services.ingestion.worker</i><br/>Adapter dispatch, dedupe,<br/>raw artifact upload"]
|
ingestion["<b>Ingestion</b><br/><i>services.ingestion.worker</i><br/>Adapter dispatch, dedupe,<br/>raw artifact upload"]
|
||||||
@@ -42,7 +42,7 @@ flowchart TB
|
|||||||
|
|
||||||
%% ── Parsing Queue ─────────────────────────────────────────────
|
%% ── Parsing Queue ─────────────────────────────────────────────
|
||||||
q_parsing[["stonks:queue:parsing"]]
|
q_parsing[["stonks:queue:parsing"]]
|
||||||
ingestion -->|"rpush<br/>(news, filings,<br/>web_scrape)"| q_parsing
|
ingestion -->|"rpush<br/>(news, filings,<br/>web_scrape, macro)"| q_parsing
|
||||||
|
|
||||||
%% ── Parser Worker ─────────────────────────────────────────────
|
%% ── Parser Worker ─────────────────────────────────────────────
|
||||||
parser["<b>Parser</b><br/><i>services.parser.worker</i><br/>HTML parsing, quality scoring,<br/>company mention detection"]
|
parser["<b>Parser</b><br/><i>services.parser.worker</i><br/>HTML parsing, quality scoring,<br/>company mention detection"]
|
||||||
@@ -50,7 +50,7 @@ flowchart TB
|
|||||||
q_parsing -->|"lpop"| parser
|
q_parsing -->|"lpop"| parser
|
||||||
|
|
||||||
minio_norm[("MinIO<br/><i>Normalized Text</i><br/><i>Parser Output JSON</i>")]
|
minio_norm[("MinIO<br/><i>Normalized Text</i><br/><i>Parser Output JSON</i>")]
|
||||||
parser -->|"upload normalized text"| minio_norm
|
parser -->|"upload normalized text<br/>+ structured output"| minio_norm
|
||||||
parser -->|"update document status,<br/>insert mentions"| pg_docs
|
parser -->|"update document status,<br/>insert mentions"| pg_docs
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,18 +70,23 @@ flowchart TB
|
|||||||
parser -->|"rpush<br/>(standard docs)"| q_extraction
|
parser -->|"rpush<br/>(standard docs)"| q_extraction
|
||||||
parser -->|"rpush<br/>(macro_event docs)"| q_macro
|
parser -->|"rpush<br/>(macro_event docs)"| q_macro
|
||||||
|
|
||||||
|
%% ── Scheduler Recovery ────────────────────────────────────────
|
||||||
|
scheduler_recovery(("Scheduler<br/><i>stale recovery &<br/>failed retry</i>"))
|
||||||
|
scheduler_recovery -.->|"re-enqueue orphaned<br/>parsed docs"| q_extraction
|
||||||
|
scheduler_recovery -.->|"re-enqueue orphaned<br/>macro docs"| q_macro
|
||||||
|
|
||||||
%% ── Extractor Worker ──────────────────────────────────────────
|
%% ── Extractor Worker ──────────────────────────────────────────
|
||||||
subgraph extractor_svc ["Extractor Service"]
|
subgraph extractor_svc ["Extractor Service"]
|
||||||
direction TB
|
direction TB
|
||||||
ext_main["<b>Extractor</b><br/><i>services.extractor.main</i><br/>Alternates between queues<br/>(2 extraction : 1 macro)"]
|
ext_main["<b>Extractor</b><br/><i>services.extractor.main</i><br/>Alternates between queues<br/>(2 extraction : 1 macro)<br/>Token budget enforcement"]
|
||||||
end
|
end
|
||||||
|
|
||||||
q_extraction -->|"lpop"| ext_main
|
q_extraction -->|"lpop"| ext_main
|
||||||
q_macro -->|"lpop"| ext_main
|
q_macro -->|"lpop"| ext_main
|
||||||
|
|
||||||
%% ── Ollama LLM ───────────────────────────────────────────────
|
%% ── Ollama LLM ───────────────────────────────────────────────
|
||||||
ollama["<b>Ollama</b><br/><i>LLM Inference</i><br/>document-extractor agent<br/>event-classifier agent"]
|
ollama["<b>Ollama / vLLM</b><br/><i>LLM Inference</i><br/>document-extractor agent<br/>event-classifier agent"]
|
||||||
ext_main <-->|"HTTP /api/generate"| ollama
|
ext_main <-->|"HTTP /api/generate<br/>(AgentConfigResolver<br/>selects model + variant)"| ollama
|
||||||
|
|
||||||
%% ── Signal Layer 1: Company ───────────────────────────────────
|
%% ── Signal Layer 1: Company ───────────────────────────────────
|
||||||
subgraph layer1 ["Layer 1 — Company Signals"]
|
subgraph layer1 ["Layer 1 — Company Signals"]
|
||||||
@@ -95,7 +100,7 @@ flowchart TB
|
|||||||
subgraph layer2 ["Layer 2 — Macro Signals"]
|
subgraph layer2 ["Layer 2 — Macro Signals"]
|
||||||
direction LR
|
direction LR
|
||||||
ge["global_events"]
|
ge["global_events"]
|
||||||
mir["macro_impact_records<br/><i>per-company interpolation</i>"]
|
mir["macro_impact_records<br/><i>per-company interpolation<br/>via exposure profiles</i>"]
|
||||||
ge --> mir
|
ge --> mir
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -106,6 +111,10 @@ flowchart TB
|
|||||||
q_agg[["stonks:queue:aggregation"]]
|
q_agg[["stonks:queue:aggregation"]]
|
||||||
ext_main -->|"rpush<br/>(per ticker)"| q_agg
|
ext_main -->|"rpush<br/>(per ticker)"| q_agg
|
||||||
|
|
||||||
|
%% ── Scheduler Periodic Aggregation ────────────────────────────
|
||||||
|
scheduler_agg(("Scheduler<br/><i>periodic aggregation<br/>every ~15 min</i>"))
|
||||||
|
scheduler_agg -.->|"rpush all<br/>active tickers"| q_agg
|
||||||
|
|
||||||
%% ── Aggregation Worker ────────────────────────────────────────
|
%% ── Aggregation Worker ────────────────────────────────────────
|
||||||
aggregation["<b>Aggregation</b><br/><i>services.aggregation.main</i><br/>Trend windows, scoring,<br/>contradiction detection"]
|
aggregation["<b>Aggregation</b><br/><i>services.aggregation.main</i><br/>Trend windows, scoring,<br/>contradiction detection"]
|
||||||
|
|
||||||
@@ -133,6 +142,8 @@ flowchart TB
|
|||||||
|
|
||||||
## Recommendation → Trading → Broker
|
## Recommendation → Trading → Broker
|
||||||
|
|
||||||
|
The recommendation worker consumes from the recommendation queue. The trading engine does **not** consume from a queue — it polls the `recommendations` table in PostgreSQL on a configurable interval, evaluates each recommendation through its decision pipeline, and pushes "act" decisions to the broker queue.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
%% ── Recommendation Queue ──────────────────────────────────────
|
%% ── Recommendation Queue ──────────────────────────────────────
|
||||||
@@ -144,19 +155,23 @@ flowchart TB
|
|||||||
|
|
||||||
q_rec -->|"lpop"| recommendation
|
q_rec -->|"lpop"| recommendation
|
||||||
|
|
||||||
ollama_thesis["<b>Ollama</b><br/><i>thesis-rewriter agent</i><br/>(optional LLM rewrite)"]
|
ollama_thesis["<b>Ollama / vLLM</b><br/><i>thesis-rewriter agent</i><br/>(AgentConfigResolver<br/>selects model + variant)"]
|
||||||
recommendation <-->|"rewrite thesis<br/>(trading-eligible only)"| ollama_thesis
|
recommendation <-->|"rewrite thesis<br/>(trading-eligible only)"| ollama_thesis
|
||||||
|
|
||||||
pg_recs[("PostgreSQL<br/><i>recommendations,<br/>recommendation_evidence,<br/>risk_evaluations</i>")]
|
pg_recs[("PostgreSQL<br/><i>recommendations,<br/>recommendation_evidence,<br/>risk_evaluations</i>")]
|
||||||
recommendation -->|"persist recommendation<br/>+ evidence + risk eval"| pg_recs
|
recommendation -->|"persist recommendation<br/>+ evidence + risk eval"| pg_recs
|
||||||
|
|
||||||
|
%% ── Lake Publication (inline) ─────────────────────────────────
|
||||||
|
minio_rec_lake[("MinIO<br/><i>Lakehouse</i><br/>recommendation facts")]
|
||||||
|
recommendation -->|"publish_recommendation_facts<br/>(Parquet)"| minio_rec_lake
|
||||||
|
|
||||||
%% ── Trading Engine ────────────────────────────────────────────
|
%% ── Trading Engine ────────────────────────────────────────────
|
||||||
subgraph trading_loop ["Trading Engine Decision Loop"]
|
subgraph trading_loop ["Trading Engine Decision Loop"]
|
||||||
direction TB
|
direction TB
|
||||||
poll["Poll recommendations<br/><i>action IN (buy, sell)<br/>mode IN (paper, live)<br/>generated_at > last_poll</i>"]
|
poll["Poll recommendations<br/><i>action IN (buy, sell)<br/>mode IN (paper, live)<br/>generated_at > last_poll</i>"]
|
||||||
dedup_check["Redis dedup check<br/><i>stonks:dedupe:trading:*</i>"]
|
dedup_check["Redis dedup check<br/><i>stonks:dedupe:trading:*</i>"]
|
||||||
evaluate["evaluate_recommendation<br/><i>Circuit breaker check<br/>Trading window check<br/>Confidence gate<br/>Sector exposure check<br/>Correlation check<br/>Earnings blackout</i>"]
|
evaluate["evaluate_recommendation<br/><i>Circuit breaker check<br/>Trading window check<br/>Confidence gate<br/>Sector exposure check<br/>Correlation check<br/>Earnings blackout<br/>Max positions check</i>"]
|
||||||
size["Position sizing<br/><i>Kelly criterion,<br/>risk tier limits</i>"]
|
size["Position sizing<br/><i>Kelly criterion,<br/>risk tier limits,<br/>micro-trade support</i>"]
|
||||||
decide{{"Decision"}}
|
decide{{"Decision"}}
|
||||||
poll --> dedup_check --> evaluate --> size --> decide
|
poll --> dedup_check --> evaluate --> size --> decide
|
||||||
end
|
end
|
||||||
@@ -170,22 +185,30 @@ flowchart TB
|
|||||||
|
|
||||||
pg_decisions[("PostgreSQL<br/><i>trading_decisions</i>")]
|
pg_decisions[("PostgreSQL<br/><i>trading_decisions</i>")]
|
||||||
|
|
||||||
|
%% ── Manual Override ───────────────────────────────────────────
|
||||||
|
trading_api(("Trading API<br/><i>POST /override/order</i>"))
|
||||||
|
trading_api -->|"rpush<br/>manual order"| q_broker
|
||||||
|
|
||||||
%% ── Broker Adapter ────────────────────────────────────────────
|
%% ── Broker Adapter ────────────────────────────────────────────
|
||||||
broker["<b>Broker Adapter</b><br/><i>services.adapters.broker_service</i><br/>Risk evaluation, idempotency,<br/>order submission, fill tracking"]
|
broker["<b>Broker Adapter</b><br/><i>services.adapters.broker_service</i><br/>Idempotency, risk evaluation,<br/>approval gate, order submission,<br/>fill tracking, position sync"]
|
||||||
|
|
||||||
q_broker -->|"lpop"| broker
|
q_broker -->|"lpop"| broker
|
||||||
|
|
||||||
%% ── Risk Engine ───────────────────────────────────────────────
|
%% ── Risk Engine ───────────────────────────────────────────────
|
||||||
risk["<b>Risk Engine</b><br/><i>services.risk.app</i><br/>POST /evaluate<br/>Approval workflow"]
|
risk["<b>Risk Engine</b><br/><i>services.risk.app</i><br/>evaluate_order()<br/>Position limits, sector exposure,<br/>daily loss caps, approval workflow"]
|
||||||
broker <-->|"evaluate order"| risk
|
broker -->|"evaluate order<br/>(inline call)"| risk
|
||||||
|
|
||||||
%% ── Alpaca ────────────────────────────────────────────────────
|
%% ── Alpaca ────────────────────────────────────────────────────
|
||||||
alpaca["<b>Alpaca</b><br/><i>Paper Trading API</i><br/>Order submission,<br/>position sync"]
|
alpaca["<b>Alpaca</b><br/><i>Paper Trading API</i><br/>Order submission,<br/>position sync,<br/>account state"]
|
||||||
broker <-->|"submit order /<br/>sync positions"| alpaca
|
broker <-->|"submit order /<br/>sync positions /<br/>sync order status"| alpaca
|
||||||
|
|
||||||
pg_orders[("PostgreSQL<br/><i>orders, order_events,<br/>positions,<br/>portfolio_snapshots</i>")]
|
pg_orders[("PostgreSQL<br/><i>orders, order_events,<br/>positions,<br/>portfolio_snapshots,<br/>broker_accounts</i>")]
|
||||||
broker -->|"persist order,<br/>events, positions"| pg_orders
|
broker -->|"persist order,<br/>events, positions"| pg_orders
|
||||||
|
|
||||||
|
%% ── Lake Publication (broker inline) ──────────────────────────
|
||||||
|
minio_broker_lake[("MinIO<br/><i>Lakehouse</i><br/>order + fill + position facts")]
|
||||||
|
broker -->|"publish_trade_order<br/>publish_trade_fill<br/>publish_positions_daily_batch<br/>(Parquet)"| minio_broker_lake
|
||||||
|
|
||||||
%% ── Notifications ─────────────────────────────────────────────
|
%% ── Notifications ─────────────────────────────────────────────
|
||||||
subgraph notifications ["Notifications"]
|
subgraph notifications ["Notifications"]
|
||||||
direction LR
|
direction LR
|
||||||
@@ -198,28 +221,32 @@ flowchart TB
|
|||||||
|
|
||||||
## Analytical Branch — Lake Publisher
|
## Analytical Branch — Lake Publisher
|
||||||
|
|
||||||
The lake publisher runs as a separate worker, consuming from its own queue and writing partitioned Parquet fact tables to MinIO for analytical queries.
|
The lake publisher runs as a separate worker, consuming from its own queue and writing partitioned Parquet fact tables to MinIO for analytical queries. Some services (broker adapter, recommendation worker) also publish facts directly to MinIO inline, bypassing the queue.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
%% ── Lake Publish Queue ────────────────────────────────────────
|
%% ── Lake Publish Queue ────────────────────────────────────────
|
||||||
q_lake[["stonks:queue:lake_publish"]]
|
q_lake[["stonks:queue:lake_publish"]]
|
||||||
|
|
||||||
various(("Various Services<br/><i>ingestion, extractor,<br/>recommendation,<br/>broker adapter</i>"))
|
various(("Upstream Services<br/><i>via enqueue_lake_job()</i>"))
|
||||||
various -->|"enqueue_lake_job"| q_lake
|
various -->|"rpush job<br/>(job_type + entity_id)"| q_lake
|
||||||
|
|
||||||
%% ── Lake Publisher Worker ─────────────────────────────────────
|
%% ── Lake Publisher Worker ─────────────────────────────────────
|
||||||
lake["<b>Lake Publisher</b><br/><i>services.lake_publisher.jobs</i><br/>Transforms operational data<br/>into analytical facts"]
|
lake["<b>Lake Publisher</b><br/><i>services.lake_publisher.jobs</i><br/>Transforms operational data<br/>into analytical facts<br/><i>15 job types supported</i>"]
|
||||||
|
|
||||||
q_lake -->|"lpop"| lake
|
q_lake -->|"lpop"| lake
|
||||||
|
|
||||||
pg_source[("PostgreSQL<br/><i>Operational Tables</i><br/>documents, extractions,<br/>orders, positions, events")]
|
pg_source[("PostgreSQL<br/><i>Operational Tables</i><br/>documents, extractions,<br/>orders, positions, events,<br/>global_events, macro_impacts,<br/>competitive_signals")]
|
||||||
lake -->|"query source data"| pg_source
|
lake -->|"query source data"| pg_source
|
||||||
|
|
||||||
%% ── MinIO Parquet ─────────────────────────────────────────────
|
%% ── MinIO Parquet ─────────────────────────────────────────────
|
||||||
minio_lake[("MinIO<br/><i>Lakehouse Bucket</i><br/>Partitioned Parquet<br/>/year=/month=/day=")]
|
minio_lake[("MinIO<br/><i>Lakehouse Bucket</i><br/>Partitioned Parquet<br/>/year=/month=/day=")]
|
||||||
lake -->|"write Parquet files"| minio_lake
|
lake -->|"write Parquet files"| minio_lake
|
||||||
|
|
||||||
|
%% ── Inline Publishers ─────────────────────────────────────────
|
||||||
|
inline(("Inline Publishers<br/><i>broker adapter,<br/>recommendation worker</i>"))
|
||||||
|
inline -->|"publish_* functions<br/>(direct Parquet write)"| minio_lake
|
||||||
|
|
||||||
%% ── Trino ─────────────────────────────────────────────────────
|
%% ── Trino ─────────────────────────────────────────────────────
|
||||||
trino["<b>Trino</b><br/><i>SQL Query Engine</i><br/>Hive connector → MinIO"]
|
trino["<b>Trino</b><br/><i>SQL Query Engine</i><br/>Hive connector → MinIO"]
|
||||||
minio_lake -->|"read via<br/>Hive Metastore"| trino
|
minio_lake -->|"read via<br/>Hive Metastore"| trino
|
||||||
@@ -238,18 +265,40 @@ flowchart LR
|
|||||||
query_api --> dashboard
|
query_api --> dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Report Generation
|
||||||
|
|
||||||
|
The scheduler manages report generation as a sub-loop, enqueuing daily and weekly report jobs to a dedicated queue and consuming them inline.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
scheduler["<b>Scheduler</b><br/><i>report schedule check</i><br/>daily @ 16:30 ET<br/>weekly @ Saturday"]
|
||||||
|
|
||||||
|
q_report[["stonks:queue:report_generation"]]
|
||||||
|
scheduler -->|"rpush<br/>(daily/weekly)"| q_report
|
||||||
|
|
||||||
|
scheduler_consumer["<b>Scheduler</b><br/><i>report consumer loop</i><br/>pops up to 5 jobs/cycle"]
|
||||||
|
q_report -->|"lpop"| scheduler_consumer
|
||||||
|
|
||||||
|
generator["<b>Report Generator</b><br/><i>services.reporting.generator</i>"]
|
||||||
|
scheduler_consumer -->|"process_report_job()"| generator
|
||||||
|
|
||||||
|
pg_reports[("PostgreSQL<br/><i>trading_reports</i>")]
|
||||||
|
generator -->|"persist report"| pg_reports
|
||||||
|
```
|
||||||
|
|
||||||
## Complete Queue Topology
|
## Complete Queue Topology
|
||||||
|
|
||||||
| Queue | Full Key | Producer(s) | Consumer |
|
| Queue | Full Key | Producer(s) | Consumer |
|
||||||
|-------|----------|-------------|----------|
|
|-------|----------|-------------|----------|
|
||||||
| Ingestion | `stonks:queue:ingestion` | Scheduler | Ingestion Worker |
|
| Ingestion | `stonks:queue:ingestion` | Scheduler (company, macro, global market sources) | Ingestion Worker |
|
||||||
| Parsing | `stonks:queue:parsing` | Ingestion Worker | Parser Worker |
|
| Parsing | `stonks:queue:parsing` | Ingestion Worker (news, filings, web_scrape, macro) | Parser Worker |
|
||||||
| Extraction | `stonks:queue:extraction` | Parser (standard docs) | Extractor Worker |
|
| Extraction | `stonks:queue:extraction` | Parser (standard docs), Scheduler (stale recovery) | Extractor Worker |
|
||||||
| Macro Classification | `stonks:queue:macro_classification` | Parser (macro_event docs), Scheduler | Extractor Worker |
|
| Macro Classification | `stonks:queue:macro_classification` | Parser (macro_event docs), Scheduler (stale/failed recovery) | Extractor Worker |
|
||||||
| Aggregation | `stonks:queue:aggregation` | Extractor Worker | Aggregation Worker |
|
| Aggregation | `stonks:queue:aggregation` | Extractor Worker (per ticker), Scheduler (periodic, all tickers) | Aggregation Worker |
|
||||||
| Recommendation | `stonks:queue:recommendation` | Aggregation Worker | Recommendation Worker |
|
| Recommendation | `stonks:queue:recommendation` | Aggregation Worker (ticker + window, 5 min dedup TTL) | Recommendation Worker |
|
||||||
| Broker Orders | `stonks:queue:broker_orders` | Trading Engine, Trading API (manual overrides) | Broker Adapter |
|
| Broker Orders | `stonks:queue:broker_orders` | Trading Engine (act decisions), Trading API (manual overrides) | Broker Adapter |
|
||||||
| Lake Publish | `stonks:queue:lake_publish` | Various services | Lake Publisher |
|
| Lake Publish | `stonks:queue:lake_publish` | Various services (via `enqueue_lake_job()`) | Lake Publisher |
|
||||||
|
| Report Generation | `stonks:queue:report_generation` | Scheduler (daily/weekly triggers) | Scheduler (inline consumer) |
|
||||||
|
|
||||||
Dead-letter queues follow the pattern `stonks:dlq:<queue_name>` and are populated when a job exhausts its retry budget.
|
Dead-letter queues follow the pattern `stonks:dlq:<queue_name>` and are populated when a job exhausts its retry budget.
|
||||||
|
|
||||||
@@ -257,18 +306,25 @@ Dead-letter queues follow the pattern `stonks:dlq:<queue_name>` and are populate
|
|||||||
|
|
||||||
| Store | Role | Key Tables / Buckets |
|
| Store | Role | Key Tables / Buckets |
|
||||||
|-------|------|---------------------|
|
|-------|------|---------------------|
|
||||||
| **PostgreSQL** | Structured operational data | `documents`, `document_intelligence`, `document_impact_records`, `global_events`, `macro_impact_records`, `competitive_signal_records`, `trend_windows`, `trend_history`, `trend_projections`, `recommendations`, `recommendation_evidence`, `risk_evaluations`, `orders`, `order_events`, `positions`, `portfolio_snapshots`, `trading_decisions` |
|
| **PostgreSQL** | Structured operational data | `documents`, `document_intelligence`, `document_impact_records`, `document_company_mentions`, `global_events`, `macro_impact_records`, `exposure_profiles`, `competitive_signal_records`, `competitor_relationships`, `trend_windows`, `trend_history`, `trend_projections`, `recommendations`, `recommendation_evidence`, `risk_evaluations`, `orders`, `order_events`, `positions`, `portfolio_snapshots`, `trading_decisions`, `circuit_breaker_events`, `reserve_pool_ledger`, `risk_tier_history`, `broker_accounts`, `ingestion_runs`, `sources`, `companies`, `company_aliases`, `ai_agents`, `agent_variants`, `agent_performance_log`, `risk_configs`, `trading_reports` |
|
||||||
| **Redis** | Queues, dedup markers, rate limits, circuit breaker state | `stonks:queue:*`, `stonks:dedupe:*`, `stonks:ratelimit:*`, `stonks:trading:circuit_breaker:*`, `stonks:dlq:*` |
|
| **Redis** | Queues, dedup markers, rate limits, circuit breaker state, pipeline toggle | `stonks:queue:*` (9 queues), `stonks:dedupe:*`, `stonks:dedupe:trading:*`, `stonks:ratelimit:*`, `stonks:trading:circuit_breaker:*`, `stonks:trading:notification_rate:*`, `stonks:order_idempotency:*`, `stonks:lock:*`, `stonks:cache:*`, `stonks:retry:*`, `stonks:rec_dedup:*`, `stonks:pipeline:enabled`, `stonks:dlq:*` |
|
||||||
| **MinIO** | Object storage for raw artifacts, normalized text, and analytical Parquet files | Raw artifacts bucket, normalized text bucket, lakehouse bucket (partitioned Parquet) |
|
| **MinIO** | Object storage for raw artifacts, normalized text, and analytical Parquet files | Raw artifacts bucket, normalized text bucket, parser output bucket, lakehouse bucket (partitioned Parquet: documents, extractions, market bars/quotes, orders, fills, positions, PnL, global events, macro impacts, trend projections, competitive signals, competitor relationships, recommendations) |
|
||||||
|
|
||||||
## External Integration Points
|
## External Integration Points
|
||||||
|
|
||||||
| Integration | Service | Protocol | Purpose |
|
| Integration | Service | Protocol | Purpose |
|
||||||
|-------------|---------|----------|---------|
|
|-------------|---------|----------|---------|
|
||||||
| **Polygon.io** | Ingestion (via adapters) | HTTPS REST | News articles, market bars, grouped daily data |
|
| **Polygon.io** | Ingestion (via PolygonNewsAdapter, PolygonMarketAdapter) | HTTPS REST | News articles, market bars, grouped daily data, intraday bars |
|
||||||
| **SEC EDGAR** | Ingestion (via FilingsDataAdapter) | HTTPS REST | 10-K, 10-Q filings |
|
| **SEC EDGAR** | Ingestion (via SECEdgarAdapter) | HTTPS REST | 10-K, 10-Q filings |
|
||||||
| **Ollama** | Extractor, Recommendation | HTTP `/api/generate` | LLM inference for document extraction, event classification, thesis rewriting |
|
| **Macro News** | Ingestion (via MacroNewsAdapter) | HTTPS REST | Geopolitical and economic event articles |
|
||||||
| **Alpaca** | Broker Adapter | HTTPS REST | Paper trading order submission, position sync, account state |
|
| **Ollama / vLLM** | Extractor, Recommendation | HTTP `/api/generate` | LLM inference for document extraction (document-extractor agent), event classification (event-classifier agent), thesis rewriting (thesis-rewriter agent). Model and variant selected via `AgentConfigResolver` with 60s TTL cache. |
|
||||||
|
| **Alpaca** | Broker Adapter | HTTPS REST | Paper/live trading: order submission, position sync, account state, order status polling |
|
||||||
| **AWS SNS** | Trading Engine (notifications) | boto3 SDK | SMS alerts for circuit breaker trips, order fills, stop-loss triggers |
|
| **AWS SNS** | Trading Engine (notifications) | boto3 SDK | SMS alerts for circuit breaker trips, order fills, stop-loss triggers |
|
||||||
| **Gmail** | Trading Engine (notifications) | SMTP (port 587 STARTTLS) | Email alerts for trading events |
|
| **Gmail** | Trading Engine (notifications) | SMTP (port 587 STARTTLS) | Email alerts for trading events |
|
||||||
| **Trino** | Query API, Superset | JDBC / HTTP | SQL queries over lakehouse Parquet files |
|
| **Trino** | Query API, Superset | HTTP | SQL queries over lakehouse Parquet files via Hive Metastore |
|
||||||
|
|
||||||
|
## Pipeline Toggle
|
||||||
|
|
||||||
|
The pipeline can be paused globally via the Redis key `stonks:pipeline:enabled`. When set to `"0"`, all queue workers (ingestion, parser, extractor, aggregation, recommendation, broker adapter, lake publisher) enter a sleep loop and stop processing jobs. The scheduler also skips scheduling cycles when the toggle is off. The toggle can be set via the Query API's pipeline control endpoints.
|
||||||
|
|
||||||
|
Setting `PIPELINE_DEFAULT_OFF=true` on the scheduler initializes the toggle to OFF on first boot, useful for staged deployments where you want to verify infrastructure before enabling the pipeline.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ graph TB
|
|||||||
subgraph trading_tier ["Trading Tier"]
|
subgraph trading_tier ["Trading Tier"]
|
||||||
direction LR
|
direction LR
|
||||||
trading_engine["trading-engine<br/><i>docker/Dockerfile</i><br/><i>uvicorn services.trading.app</i><br/>host :8002 → :8000"]
|
trading_engine["trading-engine<br/><i>docker/Dockerfile</i><br/><i>uvicorn services.trading.app</i><br/>host :8002 → :8000"]
|
||||||
risk_engine["risk-engine<br/><i>docker/Dockerfile</i><br/><i>uvicorn services.risk.app</i><br/>host :8003 → :8000"]
|
risk_engine["risk-engine<br/><i>docker/Dockerfile</i><br/><i>uvicorn services.risk.app</i><br/>host :8003 → :8000<br/><i>alias: risk</i>"]
|
||||||
broker_adapter["broker-adapter<br/><i>docker/Dockerfile</i><br/><i>python -m services.adapters.broker_service</i><br/><i>no host port</i>"]
|
broker_adapter["broker-adapter<br/><i>docker/Dockerfile</i><br/><i>python -m services.adapters.broker_service</i><br/><i>no host port</i>"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -320,3 +320,4 @@ All containers share the default Docker Compose network. Services reference each
|
|||||||
| `hive-metastore` | Hive Metastore container | trino (thrift://hive-metastore:9083) |
|
| `hive-metastore` | Hive Metastore container | trino (thrift://hive-metastore:9083) |
|
||||||
| `trino` | Trino container | superset (trino:8080) |
|
| `trino` | Trino container | superset (trino:8080) |
|
||||||
| `query-api` | Query API container | dashboard (nginx proxy upstream) |
|
| `query-api` | Query API container | dashboard (nginx proxy upstream) |
|
||||||
|
| `risk` | risk-engine container (network alias) | trading-engine (risk evaluation calls) |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ graph TB
|
|||||||
%% ── External traffic ──────────────────────────────────────────
|
%% ── External traffic ──────────────────────────────────────────
|
||||||
internet((Internet))
|
internet((Internet))
|
||||||
|
|
||||||
subgraph traefik ["kube-system (Traefik Ingress Controller)"]
|
subgraph traefik ["kube-system · Traefik Ingress Controller"]
|
||||||
direction LR
|
direction LR
|
||||||
ing_dash["stonks.celestium.life"]
|
ing_dash["stonks.celestium.life"]
|
||||||
ing_api["stonks-api.celestium.life"]
|
ing_api["stonks-api.celestium.life"]
|
||||||
@@ -28,47 +28,55 @@ graph TB
|
|||||||
direction TB
|
direction TB
|
||||||
|
|
||||||
%% ── API Tier (ingress-facing) ─────────────────────────────
|
%% ── API Tier (ingress-facing) ─────────────────────────────
|
||||||
subgraph api_tier ["API Tier"]
|
subgraph api_tier ["API Tier · tier: api"]
|
||||||
direction LR
|
direction LR
|
||||||
query_api["query-api<br/><i>Deployment (1 replica)</i><br/>:8000"]
|
query_api["query-api<br/><i>Deployment · 1 replica</i><br/>:8000<br/><i>readiness: /docs</i>"]
|
||||||
symbol_registry["symbol-registry<br/><i>Deployment (1 replica)</i><br/>:8000"]
|
symbol_registry["symbol-registry<br/><i>Deployment · 1 replica</i><br/>:8000<br/><i>readiness: /docs · liveness: /docs</i>"]
|
||||||
end
|
end
|
||||||
|
|
||||||
%% ── Frontend Tier ─────────────────────────────────────────
|
%% ── Frontend Tier ─────────────────────────────────────────
|
||||||
subgraph frontend_tier ["Frontend Tier"]
|
subgraph frontend_tier ["Frontend Tier · tier: frontend"]
|
||||||
dashboard["dashboard<br/><i>Deployment (1 replica)</i><br/>:8080<br/><i>nginx-unprivileged</i>"]
|
dashboard["dashboard<br/><i>Deployment · 1 replica</i><br/>:8080<br/><i>nginx-unprivileged</i><br/><i>readiness: / · liveness: /</i>"]
|
||||||
end
|
end
|
||||||
|
|
||||||
%% ── Trading Tier ──────────────────────────────────────────
|
%% ── Trading Tier ──────────────────────────────────────────
|
||||||
subgraph trading_tier ["Trading Tier"]
|
subgraph trading_tier ["Trading Tier · tier: trading"]
|
||||||
direction LR
|
direction LR
|
||||||
trading_engine["trading-engine<br/><i>Deployment (1 replica)</i><br/>:8000"]
|
trading_engine["trading-engine<br/><i>Deployment · 1 replica</i><br/>:8000<br/><i>readiness: /ready · liveness: /health</i>"]
|
||||||
risk_engine["risk-engine<br/><i>Deployment (1 replica)</i><br/>:8000"]
|
risk_engine["risk-engine<br/><i>Deployment · 1 replica</i><br/>:8000"]
|
||||||
broker_adapter["broker-adapter<br/><i>Deployment (1 replica)</i><br/><i>queue-driven worker</i>"]
|
broker_adapter["broker-adapter<br/><i>Deployment · 1 replica</i><br/><i>queue-driven worker · pipeline-gated</i>"]
|
||||||
end
|
end
|
||||||
|
|
||||||
%% ── Orchestration Tier ────────────────────────────────────
|
%% ── Orchestration Tier ────────────────────────────────────
|
||||||
subgraph orchestration_tier ["Orchestration Tier"]
|
subgraph orchestration_tier ["Orchestration Tier · tier: orchestration"]
|
||||||
scheduler["scheduler<br/><i>Deployment (1 replica)</i><br/><i>runs migrations + seed</i>"]
|
scheduler["scheduler<br/><i>Deployment · 1 replica · pipeline-gated</i><br/><i>init: migrations → seed → backfill</i>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% ── Ingestion Tier ────────────────────────────────────────
|
||||||
|
subgraph ingestion_tier ["Ingestion Tier · tier: ingestion"]
|
||||||
|
ingestion["ingestion<br/><i>Deployment · 1 replica · pipeline-gated</i><br/><i>queue-driven worker</i>"]
|
||||||
end
|
end
|
||||||
|
|
||||||
%% ── Processing Tier (pipeline workers) ────────────────────
|
%% ── Processing Tier (pipeline workers) ────────────────────
|
||||||
subgraph processing_tier ["Processing Tier (pipeline workers)"]
|
subgraph processing_tier ["Processing Tier · tier: processing"]
|
||||||
direction LR
|
direction LR
|
||||||
ingestion["ingestion<br/><i>Deployment (2 replicas)</i>"]
|
parser["parser<br/><i>Deployment · 2 replicas · pipeline-gated</i>"]
|
||||||
parser["parser<br/><i>Deployment (2 replicas)</i>"]
|
extractor["extractor<br/><i>Deployment · 1 replica · pipeline-gated</i>"]
|
||||||
extractor["extractor<br/><i>Deployment (1 replica)</i>"]
|
aggregation["aggregation<br/><i>Deployment · 4 replicas · pipeline-gated</i>"]
|
||||||
aggregation["aggregation<br/><i>Deployment (4 replicas)</i>"]
|
recommendation["recommendation<br/><i>Deployment · 1 replica · pipeline-gated</i>"]
|
||||||
recommendation["recommendation<br/><i>Deployment (1 replica)</i>"]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
%% ── Analytics Tier ────────────────────────────────────────
|
%% ── Analytics Tier ────────────────────────────────────────
|
||||||
subgraph analytics_tier ["Analytics Tier"]
|
subgraph analytics_tier ["Analytics Tier · tier: analytics"]
|
||||||
direction LR
|
direction LR
|
||||||
lake_publisher["lake-publisher<br/><i>Deployment (1 replica)</i><br/><i>queue-driven worker</i>"]
|
lake_publisher["lake-publisher<br/><i>Deployment · 1 replica · pipeline-gated</i><br/><i>queue-driven worker</i>"]
|
||||||
hive_metastore["hive-metastore<br/><i>Deployment (1 replica)</i><br/>:9083<br/><i>apache/hive:4.0.0</i>"]
|
hive_metastore["hive-metastore<br/><i>Deployment · 1 replica</i><br/>:9083<br/><i>apache/hive:4.0.0</i><br/><i>PVC: hive-metastore-data</i>"]
|
||||||
trino["trino<br/><i>Deployment (1 replica)</i><br/>:8080<br/><i>trinodb/trino:latest</i>"]
|
trino["trino<br/><i>Deployment · 1 replica</i><br/>:8080<br/><i>trinodb/trino:latest</i><br/><i>readiness: /v1/info</i>"]
|
||||||
superset["superset<br/><i>Deployment (1 replica)</i><br/>:8088<br/><i>custom image</i>"]
|
end
|
||||||
|
|
||||||
|
%% ── Superset (tier: dashboard in template) ────────────────
|
||||||
|
subgraph superset_block ["Superset · tier: dashboard"]
|
||||||
|
superset["superset<br/><i>Deployment · 1 replica</i><br/>:8088<br/><i>custom image</i><br/><i>PVC: superset-data</i><br/><i>readiness: /health</i>"]
|
||||||
end
|
end
|
||||||
|
|
||||||
%% ── Helm Secrets ──────────────────────────────────────────
|
%% ── Helm Secrets ──────────────────────────────────────────
|
||||||
@@ -99,7 +107,7 @@ graph TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph ollama_ns ["ollama-service namespace"]
|
subgraph ollama_ns ["ollama-service namespace"]
|
||||||
ollama[("Ollama<br/>ollama:11434<br/><i>GPU: 4070 Ti Super</i>")]
|
ollama[("Ollama<br/>ollama:11434<br/><i>GPU: 4070 Ti Super 16GB</i>")]
|
||||||
end
|
end
|
||||||
|
|
||||||
%% ── Ingress Routes ────────────────────────────────────────────
|
%% ── Ingress Routes ────────────────────────────────────────────
|
||||||
@@ -191,6 +199,7 @@ graph TB
|
|||||||
sec_broker -.-> broker_adapter
|
sec_broker -.-> broker_adapter
|
||||||
|
|
||||||
sec_market -.-> ingestion
|
sec_market -.-> ingestion
|
||||||
|
sec_market -.-> query_api
|
||||||
|
|
||||||
sec_gmail -.-> trading_engine
|
sec_gmail -.-> trading_engine
|
||||||
|
|
||||||
@@ -216,7 +225,9 @@ graph TB
|
|||||||
classDef tradingSvc fill:#e8a838,stroke:#b07d1a,color:#fff
|
classDef tradingSvc fill:#e8a838,stroke:#b07d1a,color:#fff
|
||||||
classDef processSvc fill:#9b59b6,stroke:#6c3483,color:#fff
|
classDef processSvc fill:#9b59b6,stroke:#6c3483,color:#fff
|
||||||
classDef orchSvc fill:#1abc9c,stroke:#148f77,color:#fff
|
classDef orchSvc fill:#1abc9c,stroke:#148f77,color:#fff
|
||||||
|
classDef ingestionSvc fill:#e67e22,stroke:#bf6516,color:#fff
|
||||||
classDef analyticsSvc fill:#e74c3c,stroke:#a93226,color:#fff
|
classDef analyticsSvc fill:#e74c3c,stroke:#a93226,color:#fff
|
||||||
|
classDef supersetSvc fill:#c0392b,stroke:#96281b,color:#fff
|
||||||
classDef extSvc fill:#95a5a6,stroke:#717d7e,color:#fff
|
classDef extSvc fill:#95a5a6,stroke:#717d7e,color:#fff
|
||||||
classDef secretSvc fill:#f5f5dc,stroke:#999,color:#333
|
classDef secretSvc fill:#f5f5dc,stroke:#999,color:#333
|
||||||
classDef configSvc fill:#dfe6e9,stroke:#999,color:#333
|
classDef configSvc fill:#dfe6e9,stroke:#999,color:#333
|
||||||
@@ -225,8 +236,10 @@ graph TB
|
|||||||
class dashboard frontendSvc
|
class dashboard frontendSvc
|
||||||
class trading_engine,risk_engine,broker_adapter tradingSvc
|
class trading_engine,risk_engine,broker_adapter tradingSvc
|
||||||
class scheduler orchSvc
|
class scheduler orchSvc
|
||||||
class ingestion,parser,extractor,aggregation,recommendation processSvc
|
class ingestion ingestionSvc
|
||||||
class lake_publisher,hive_metastore,trino,superset analyticsSvc
|
class parser,extractor,aggregation,recommendation processSvc
|
||||||
|
class lake_publisher,hive_metastore,trino analyticsSvc
|
||||||
|
class superset supersetSvc
|
||||||
class postgres,redis,minio,ollama extSvc
|
class postgres,redis,minio,ollama extSvc
|
||||||
class sec_core,sec_broker,sec_market,sec_gmail,sec_dashboard secretSvc
|
class sec_core,sec_broker,sec_market,sec_gmail,sec_dashboard secretSvc
|
||||||
class configmap configSvc
|
class configmap configSvc
|
||||||
@@ -284,8 +297,8 @@ The following services have **no inbound network policy** — they are queue-dri
|
|||||||
|
|
||||||
| Service | Tier | Behavior |
|
| Service | Tier | Behavior |
|
||||||
|---------|------|----------|
|
|---------|------|----------|
|
||||||
| scheduler | orchestration | Polls DB, enqueues to Redis |
|
| scheduler | orchestration | Polls DB, enqueues to Redis. Runs migrations + seed + backfill as init containers |
|
||||||
| ingestion | processing | Reads from `stonks:queue:ingestion`, writes to DB/MinIO/Redis |
|
| ingestion | ingestion | Reads from `stonks:queue:ingestion`, writes to DB/MinIO/Redis. Egress to Polygon.io/News APIs |
|
||||||
| parser | processing | Reads from `stonks:queue:parsing`, writes to DB/Redis |
|
| parser | processing | Reads from `stonks:queue:parsing`, writes to DB/Redis |
|
||||||
| extractor | processing | Reads from `stonks:queue:extraction`, calls Ollama, writes to DB/Redis |
|
| extractor | processing | Reads from `stonks:queue:extraction`, calls Ollama, writes to DB/Redis |
|
||||||
| aggregation | processing | Reads from `stonks:queue:aggregation`, writes to DB/Redis |
|
| aggregation | processing | Reads from `stonks:queue:aggregation`, writes to DB/Redis |
|
||||||
@@ -294,22 +307,24 @@ The following services have **no inbound network policy** — they are queue-dri
|
|||||||
|
|
||||||
## Service Tier Summary
|
## Service Tier Summary
|
||||||
|
|
||||||
| Tier | Services | Ingress? | Replicas | Notes |
|
| Tier | Services | Ingress? | Replicas | Pipeline-Gated? | Notes |
|
||||||
|------|----------|----------|----------|-------|
|
|------|----------|----------|----------|-----------------|-------|
|
||||||
| **api** | query-api, symbol-registry | Yes (Traefik) | 1 each | FastAPI, readiness probes on `/docs` |
|
| **api** | query-api, symbol-registry | Yes (Traefik) | 1 each | No | FastAPI, readiness probes on `/docs` |
|
||||||
| **frontend** | dashboard | Yes (Traefik) | 1 | nginx-unprivileged on :8080, proxies to API services |
|
| **frontend** | dashboard | Yes (Traefik) | 1 | No | nginx-unprivileged on :8080, proxies to API services |
|
||||||
| **trading** | trading-engine, risk-engine, broker-adapter | trading-engine: Yes; risk-engine: internal only; broker-adapter: denied | 1 each | trading-engine has egress to Alpaca + Gmail |
|
| **trading** | trading-engine, risk-engine, broker-adapter | trading-engine: Yes; risk-engine: internal only; broker-adapter: denied | 1 each | broker-adapter only | trading-engine has egress to Alpaca + Gmail |
|
||||||
| **orchestration** | scheduler | No | 1 | Runs DB migrations + seed as init containers |
|
| **orchestration** | scheduler | No | 1 | Yes | Runs DB migrations + seed + backfill as init containers |
|
||||||
| **processing** | ingestion, parser, extractor, aggregation, recommendation | No | 2, 2, 1, 4, 1 | Pipeline-gated by `pipelineEnabled` toggle |
|
| **ingestion** | ingestion | No | 1 | Yes | Fetches from external APIs (Polygon.io, news, filings) |
|
||||||
| **analytics** | lake-publisher, trino, hive-metastore, superset | trino + superset: Yes; others: No | 1 each | lake-publisher is pipeline-gated |
|
| **processing** | parser, extractor, aggregation, recommendation | No | 2, 1, 4, 1 | Yes | Queue-driven pipeline workers |
|
||||||
|
| **analytics** | lake-publisher, trino, hive-metastore | trino: Yes (Traefik); others: No | 1 each | lake-publisher only | trino + hive-metastore gated by `trino.enabled` / `hiveMetastore.enabled` |
|
||||||
|
| **dashboard** (Superset) | superset | Yes (Traefik) | 1 | No | Gated by `superset.enabled`, custom image with trino + psycopg2 drivers |
|
||||||
|
|
||||||
## Secret Consumption Map
|
## Secret Consumption Map
|
||||||
|
|
||||||
| Secret | Keys | Consumers |
|
| Secret | Keys | Consumers |
|
||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
| `stonks-core-secrets` | POSTGRES_PASSWORD, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, REDIS_PASSWORD | All 13 app services + hive-metastore, trino, superset |
|
| `stonks-core-secrets` | POSTGRES_PASSWORD, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, REDIS_PASSWORD | All 13 app services + hive-metastore (init), trino (init), superset |
|
||||||
| `stonks-broker-secrets` | BROKER_API_KEY, BROKER_API_SECRET, BROKER_BASE_URL | ingestion, trading-engine, risk-engine, broker-adapter |
|
| `stonks-broker-secrets` | BROKER_API_KEY, BROKER_API_SECRET, BROKER_BASE_URL | ingestion, trading-engine, risk-engine, broker-adapter |
|
||||||
| `stonks-market-secrets` | MARKET_DATA_API_KEY | ingestion |
|
| `stonks-market-secrets` | MARKET_DATA_API_KEY | ingestion, query-api |
|
||||||
| `stonks-gmail-secrets` | GMAIL_SENDER, GMAIL_RECIPIENT, GMAIL_APP_PASSWORD | trading-engine |
|
| `stonks-gmail-secrets` | GMAIL_SENDER, GMAIL_RECIPIENT, GMAIL_APP_PASSWORD | trading-engine |
|
||||||
| `stonks-dashboard-secrets` | SUPERSET_SECRET_KEY, SUPERSET_ADMIN_PASSWORD | superset |
|
| `stonks-dashboard-secrets` | SUPERSET_SECRET_KEY, SUPERSET_ADMIN_PASSWORD | superset |
|
||||||
|
|
||||||
@@ -336,10 +351,10 @@ These services run outside the `stonks-oracle` namespace and are referenced via
|
|||||||
|
|
||||||
The analytics stack runs within the `stonks-oracle` namespace:
|
The analytics stack runs within the `stonks-oracle` namespace:
|
||||||
|
|
||||||
1. **Lake Publisher** writes Parquet fact tables to MinIO at `s3a://stonks-lakehouse/warehouse`
|
1. **Lake Publisher** writes Parquet fact tables to MinIO at `s3a://stonks-lakehouse/warehouse`. Pipeline-gated — scales to 0 when `pipelineEnabled: false`.
|
||||||
2. **Hive Metastore** (Apache Hive 4.0.0) manages table metadata, backed by embedded Derby DB with a PVC for persistence. Connects to MinIO for S3A filesystem access.
|
2. **Hive Metastore** (Apache Hive 4.0.0) manages table metadata, backed by embedded Derby DB with a PVC (`hive-metastore-data`) for persistence. Connects to MinIO for S3A filesystem access. Gated by `hiveMetastore.enabled`.
|
||||||
3. **Trino** queries the lakehouse via Hive Metastore (thrift://hive-metastore:9083). Exposes two catalogs: `lakehouse` (Hive connector) and `iceberg` (Iceberg connector). Both connect to MinIO for data access.
|
3. **Trino** queries the lakehouse via Hive Metastore (`thrift://hive-metastore:9083`). Exposes two catalogs: `lakehouse` (Hive connector) and `iceberg` (Iceberg connector). Both connect to MinIO for data access. Gated by `trino.enabled`. Readiness probe on `/v1/info`.
|
||||||
4. **Superset** connects to Trino for lakehouse queries and to PostgreSQL for its metadata DB. Uses Redis for caching. Exposed externally via Traefik ingress.
|
4. **Superset** connects to Trino for lakehouse queries and to PostgreSQL for its metadata DB. Uses Redis for caching. Exposed externally via Traefik ingress. Gated by `superset.enabled`. Uses custom image (`registry.celestium.life/stonks-oracle/superset:latest`) with trino + psycopg2 drivers. PVC (`superset-data`) for persistence.
|
||||||
|
|
||||||
## Ingress Routes
|
## Ingress Routes
|
||||||
|
|
||||||
@@ -353,3 +368,13 @@ All ingress resources use the `traefik` IngressClass with TLS certificates issue
|
|||||||
| `stonks-trading.celestium.life` | trading-engine | 8000 | `stonks-trading-tls` |
|
| `stonks-trading.celestium.life` | trading-engine | 8000 | `stonks-trading-tls` |
|
||||||
| `stonks-dash.celestium.life` | superset | 8088 | `stonks-dash-tls` |
|
| `stonks-dash.celestium.life` | superset | 8088 | `stonks-dash-tls` |
|
||||||
| `stonks-trino.celestium.life` | trino | 8080 | `stonks-trino-tls` |
|
| `stonks-trino.celestium.life` | trino | 8080 | `stonks-trino-tls` |
|
||||||
|
|
||||||
|
## Deployment Stages
|
||||||
|
|
||||||
|
The Helm chart supports multiple deployment stages via value override files:
|
||||||
|
|
||||||
|
| Stage | Override File | Namespace | Key Differences |
|
||||||
|
|-------|--------------|-----------|-----------------|
|
||||||
|
| **Production** | `values.yaml` (base) | `stonks-oracle` | Full analytics stack, all services |
|
||||||
|
| **Paper** | `values-paper.yaml` | `stonks-oracle` | `BROKER_MODE=paper`, `DEPLOY_STAGE=paper`, separate DB (`stonks_paper`), Redis DB 2, paper-specific ingress hostnames |
|
||||||
|
| **Beta** | `values-beta.yaml` | `stonks-oracle-beta` | `DEPLOY_STAGE=beta`, `LOG_LEVEL=DEBUG`, separate DB (`stonks_beta`), Redis DB 1, analytics stack disabled, beta-specific ingress hostnames |
|
||||||
|
|||||||
+180
-29
@@ -5,6 +5,7 @@ This guide covers running the full Stonks Oracle platform locally using Docker C
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Docker Engine 24+ and Docker Compose v2
|
- Docker Engine 24+ and Docker Compose v2
|
||||||
|
- NVIDIA GPU with drivers and NVIDIA Container Toolkit (for Ollama LLM inference)
|
||||||
- At least 16 GB RAM (Ollama + Trino + all services)
|
- At least 16 GB RAM (Ollama + Trino + all services)
|
||||||
- API keys for Polygon.io and Alpaca (optional — platform runs in degraded mode without them)
|
- API keys for Polygon.io and Alpaca (optional — platform runs in degraded mode without them)
|
||||||
|
|
||||||
@@ -14,20 +15,54 @@ This guide covers running the full Stonks Oracle platform locally using Docker C
|
|||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone <repo-url> && cd stonks-oracle
|
git clone <repo-url> && cd stonks-oracle
|
||||||
|
|
||||||
# 2. Configure API keys
|
# 2. Configure API keys (create .env in the repo root)
|
||||||
cp .env.example .env # or edit the existing .env
|
cat > .env <<'EOF'
|
||||||
# Fill in MARKET_DATA_API_KEY, BROKER_API_KEY, BROKER_API_SECRET
|
MARKET_DATA_API_KEY=your_polygon_key
|
||||||
|
BROKER_API_KEY=your_alpaca_key
|
||||||
|
BROKER_API_SECRET=your_alpaca_secret
|
||||||
|
BROKER_BASE_URL=https://paper-api.alpaca.markets
|
||||||
|
EOF
|
||||||
|
|
||||||
# 3. Start everything
|
# 3. Start everything
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 4. Verify all services are healthy
|
# 4. Pull an LLM model into Ollama
|
||||||
|
docker compose exec ollama ollama pull qwen3.5:9b-fast
|
||||||
|
|
||||||
|
# 5. Seed the database
|
||||||
|
docker compose exec scheduler python -m services.symbol_registry.seed
|
||||||
|
|
||||||
|
# 6. Verify all services are healthy
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
# 5. Access the dashboard
|
# 7. Access the dashboard
|
||||||
open http://localhost:3000
|
open http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Automated Deployment
|
||||||
|
|
||||||
|
The `deploy-docker.sh` script automates the full deployment to a remote host via SSH, including prerequisite installation, repository sync, environment configuration, image builds, service startup, database seeding, and Ollama model pulling:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy with defaults (GPU-accelerated Docker Ollama)
|
||||||
|
bash deploy-docker.sh
|
||||||
|
|
||||||
|
# Specify a custom Ollama model
|
||||||
|
bash deploy-docker.sh --ollama-model qwen3.6
|
||||||
|
|
||||||
|
# Deploy to a different host
|
||||||
|
bash deploy-docker.sh --host user@myserver --dir /opt/stonks
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `--host` | `celes@192.168.42.254` | SSH target (`USER@HOST`) |
|
||||||
|
| `--ollama-url` | (auto — Docker container) | Ollama API URL |
|
||||||
|
| `--ollama-model` | `qwen3.5:9b-fast` | Ollama model to pull |
|
||||||
|
| `--dir` | `~/stonks-oracle` | Remote install directory |
|
||||||
|
|
||||||
|
The script detects the target OS and package manager (apt, dnf, yum, pacman, zypper) and installs Docker, NVIDIA drivers, and the NVIDIA Container Toolkit as needed. It also handles WSL environments and firewall configuration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Service Inventory
|
## Service Inventory
|
||||||
@@ -63,6 +98,8 @@ open http://localhost:3000
|
|||||||
| `query-api` | `docker/Dockerfile` | `uvicorn services.api.app:app --host 0.0.0.0 --port 8000` | `8004:8000` | postgres (healthy), redis (healthy), minio (healthy) |
|
| `query-api` | `docker/Dockerfile` | `uvicorn services.api.app:app --host 0.0.0.0 --port 8000` | `8004:8000` | postgres (healthy), redis (healthy), minio (healthy) |
|
||||||
| `dashboard` | `frontend/Dockerfile` | nginx (built-in) | `3000:8080` | query-api (healthy) |
|
| `dashboard` | `frontend/Dockerfile` | nginx (built-in) | `3000:8080` | query-api (healthy) |
|
||||||
|
|
||||||
|
The `risk-engine` service has a Docker network alias of `risk` so the dashboard's nginx reverse proxy can resolve it as `http://risk:8000`.
|
||||||
|
|
||||||
### Port Summary
|
### Port Summary
|
||||||
|
|
||||||
| Port | Service | Protocol |
|
| Port | Service | Protocol |
|
||||||
@@ -109,15 +146,27 @@ The `.env` file is loaded by `ingestion`, `broker-adapter`, and `trading-engine`
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
# Stonks Oracle — Environment Variables
|
# Stonks Oracle — Environment Variables
|
||||||
# These are loaded by ingestion, broker-adapter, and trading-engine services.
|
# Loaded by: ingestion, broker-adapter, trading-engine
|
||||||
|
|
||||||
# Polygon.io market data API key (required for live data ingestion)
|
# ── Required for live data ingestion ──
|
||||||
MARKET_DATA_API_KEY=
|
MARKET_DATA_API_KEY=
|
||||||
|
|
||||||
# Alpaca broker credentials (required for paper/live trading)
|
# ── Required for paper/live trading ──
|
||||||
BROKER_API_KEY=
|
BROKER_API_KEY=
|
||||||
BROKER_API_SECRET=
|
BROKER_API_SECRET=
|
||||||
BROKER_BASE_URL=https://paper-api.alpaca.markets
|
BROKER_BASE_URL=https://paper-api.alpaca.markets
|
||||||
|
|
||||||
|
# ── Trading engine settings (optional) ──
|
||||||
|
TRADING_ENABLED=true
|
||||||
|
TRADING_RISK_TIER=moderate
|
||||||
|
TRADING_MAX_OPEN_POSITIONS=15
|
||||||
|
|
||||||
|
# ── LLM model (optional) ──
|
||||||
|
OLLAMA_MODEL=qwen3.5:9b-fast
|
||||||
|
|
||||||
|
# ── Signal layers (optional) ──
|
||||||
|
MACRO_ENABLED=true
|
||||||
|
COMPETITIVE_ENABLED=true
|
||||||
```
|
```
|
||||||
|
|
||||||
| Variable | Required | Default | Used By | Description |
|
| Variable | Required | Default | Used By | Description |
|
||||||
@@ -178,20 +227,24 @@ All application services support additional environment variables loaded via `se
|
|||||||
| `REDIS_DB` | `0` | Redis database number |
|
| `REDIS_DB` | `0` | Redis database number |
|
||||||
| `REDIS_PASSWORD` | (none) | Redis password (not needed in Docker Compose) |
|
| `REDIS_PASSWORD` | (none) | Redis password (not needed in Docker Compose) |
|
||||||
| `MINIO_SECURE` | `false` | Use HTTPS for MinIO |
|
| `MINIO_SECURE` | `false` | Use HTTPS for MinIO |
|
||||||
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama LLM server URL |
|
|
||||||
| `OLLAMA_MODEL` | `qwen3.5:9b` | Default LLM model for extraction |
|
| `OLLAMA_MODEL` | `qwen3.5:9b` | Default LLM model for extraction |
|
||||||
| `OLLAMA_TIMEOUT` | `120` | Ollama request timeout (seconds) |
|
| `OLLAMA_TIMEOUT` | `120` | Ollama request timeout (seconds) |
|
||||||
| `OLLAMA_MAX_RETRIES` | `2` | Max retries for Ollama requests |
|
| `OLLAMA_MAX_RETRIES` | `2` | Max retries for Ollama requests |
|
||||||
| `VLLM_BASE_URL` | (empty) | vLLM server URL (if using vLLM instead of Ollama) |
|
| `OLLAMA_RETRY_BASE_DELAY` | `1.0` | Base delay between retries (seconds) |
|
||||||
| `VLLM_MODEL` | (empty) | vLLM model name (e.g. `AxionML/Qwen3.5-9B-NVFP4`) |
|
| `OLLAMA_RETRY_MAX_DELAY` | `10.0` | Maximum delay between retries (seconds) |
|
||||||
|
| `OLLAMA_RETRY_BACKOFF_MULTIPLIER` | `2.0` | Backoff multiplier for retries |
|
||||||
|
| `VLLM_BASE_URL` | `http://192.168.42.254:8000` | vLLM server URL (if using vLLM instead of Ollama) |
|
||||||
|
| `VLLM_MODEL` | `RedHatAI/Qwen3.6-35B-A3B-NVFP4` | vLLM model name |
|
||||||
| `VLLM_TIMEOUT` | `120` | vLLM request timeout (seconds) |
|
| `VLLM_TIMEOUT` | `120` | vLLM request timeout (seconds) |
|
||||||
| `VLLM_MAX_RETRIES` | `2` | Max retries for vLLM requests |
|
| `VLLM_MAX_RETRIES` | `2` | Max retries for vLLM requests |
|
||||||
| `VLLM_TEMPERATURE` | `0.7` | vLLM sampling temperature |
|
| `VLLM_TEMPERATURE` | `0.7` | vLLM sampling temperature |
|
||||||
|
| `VLLM_MAX_TOKENS` | `4096` | vLLM max output tokens |
|
||||||
| `VLLM_API_KEY` | (empty) | vLLM API key (if required) |
|
| `VLLM_API_KEY` | (empty) | vLLM API key (if required) |
|
||||||
| `TRINO_HOST` | `localhost` | Trino hostname |
|
| `TRINO_HOST` | `localhost` | Trino hostname |
|
||||||
| `TRINO_PORT` | `8080` | Trino port |
|
| `TRINO_PORT` | `8080` | Trino port |
|
||||||
| `TRINO_CATALOG` | `lakehouse` | Trino catalog name |
|
| `TRINO_CATALOG` | `lakehouse` | Trino catalog name |
|
||||||
| `TRINO_SCHEMA` | `stonks` | Trino schema name |
|
| `TRINO_SCHEMA` | `stonks` | Trino schema name |
|
||||||
|
| `TRINO_ICEBERG_CATALOG` | `iceberg` | Trino Iceberg catalog name |
|
||||||
| `MARKET_DATA_BASE_URL` | `https://api.polygon.io` | Polygon.io base URL |
|
| `MARKET_DATA_BASE_URL` | `https://api.polygon.io` | Polygon.io base URL |
|
||||||
| `MARKET_DATA_PROVIDER` | `polygon` | Market data provider |
|
| `MARKET_DATA_PROVIDER` | `polygon` | Market data provider |
|
||||||
| `BROKER_MODE` | `paper` | Broker mode: `paper` or `live` |
|
| `BROKER_MODE` | `paper` | Broker mode: `paper` or `live` |
|
||||||
@@ -200,12 +253,62 @@ All application services support additional environment variables loaded via `se
|
|||||||
| `TRADING_RISK_TIER` | `moderate` | Risk tier: `conservative`, `moderate`, `aggressive` |
|
| `TRADING_RISK_TIER` | `moderate` | Risk tier: `conservative`, `moderate`, `aggressive` |
|
||||||
| `TRADING_POLLING_INTERVAL_SECONDS` | `60` | Recommendation polling interval |
|
| `TRADING_POLLING_INTERVAL_SECONDS` | `60` | Recommendation polling interval |
|
||||||
| `TRADING_MAX_OPEN_POSITIONS` | `10` | Maximum concurrent open positions |
|
| `TRADING_MAX_OPEN_POSITIONS` | `10` | Maximum concurrent open positions |
|
||||||
|
| `TRADING_RESERVE_SIPHON_PCT` | `0.20` | Percentage of profits siphoned to reserve pool |
|
||||||
|
| `TRADING_STOP_LOSS_CHECK_INTERVAL_SECONDS` | `300` | Stop-loss check interval |
|
||||||
|
| `TRADING_FAST_STOP_LOSS_INTERVAL_SECONDS` | `60` | Fast stop-loss check interval |
|
||||||
|
| `TRADING_GRADUAL_ENTRY_TRANCHES` | `3` | Number of tranches for gradual entry |
|
||||||
|
| `TRADING_GRADUAL_ENTRY_THRESHOLD_DOLLARS` | `30.0` | Dollar threshold for gradual entry |
|
||||||
|
| `TRADING_ABSOLUTE_POSITION_CAP` | `50.0` | Maximum position size (dollars) |
|
||||||
|
| `TRADING_ACTIVE_POOL_MINIMUM` | `100.0` | Minimum active pool balance |
|
||||||
|
| `TRADING_EMERGENCY_DRAWDOWN_THRESHOLD_PCT` | `0.40` | Emergency drawdown threshold |
|
||||||
|
| `TRADING_RESERVE_HIGH_WATER_PCT` | `0.30` | Reserve high-water mark percentage |
|
||||||
|
| `TRADING_MICRO_TRADING_ENABLED` | `false` | Enable micro-trading mode |
|
||||||
|
| `TRADING_MICRO_TRADING_INTERVAL_SECONDS` | `300` | Micro-trading polling interval |
|
||||||
|
| `TRADING_MICRO_TRADING_ALLOCATION_CAP_PCT` | `0.03` | Micro-trading allocation cap |
|
||||||
|
| `TRADING_MICRO_TRADING_MAX_DAILY` | `10` | Max micro-trades per day |
|
||||||
|
| `TRADING_MICRO_TRADING_MAX_HOLD_MINUTES` | `120` | Max micro-trade hold time |
|
||||||
|
| `TRADING_SNS_TOPIC_ARN` | (empty) | AWS SNS topic ARN for notifications |
|
||||||
|
| `TRADING_SNS_PHONE_NUMBER` | (empty) | Phone number for SNS notifications |
|
||||||
|
| `TRADING_GMAIL_SENDER` | (empty) | Gmail sender address for notifications |
|
||||||
|
| `TRADING_GMAIL_RECIPIENT` | (empty) | Gmail recipient address for notifications |
|
||||||
| `MACRO_ENABLED` | `true` | Enable macro signal layer |
|
| `MACRO_ENABLED` | `true` | Enable macro signal layer |
|
||||||
|
| `MACRO_SIGNAL_WEIGHT` | `0.3` | Relative weight of macro vs company signals |
|
||||||
|
| `MACRO_CONFIDENCE_THRESHOLD` | `0.4` | Minimum confidence for macro event inclusion |
|
||||||
|
| `MACRO_SHORT_TERM_STALENESS_HOURS` | `48` | Hours before short-term events get accelerated decay |
|
||||||
|
| `PROJECTION_CONFIDENCE_THRESHOLD` | `0.3` | Minimum confidence for projections to influence recommendations |
|
||||||
| `COMPETITIVE_ENABLED` | `true` | Enable competitive signal layer |
|
| `COMPETITIVE_ENABLED` | `true` | Enable competitive signal layer |
|
||||||
|
| `COMPETITIVE_SIGNAL_WEIGHT` | `0.2` | Relative weight of competitive signals |
|
||||||
|
| `COMPETITIVE_PATTERN_CONFIDENCE_THRESHOLD` | `0.3` | Minimum confidence for pattern inclusion |
|
||||||
|
| `COMPETITIVE_PROPAGATION_STRENGTH_THRESHOLD` | `0.2` | Minimum strength for signal propagation |
|
||||||
|
| `COMPETITIVE_ROUTINE_LOOKBACK_DAYS` | `180` | Lookback window for routine patterns |
|
||||||
|
| `COMPETITIVE_MAJOR_DECISION_LOOKBACK_DAYS` | `365` | Lookback window for major decisions |
|
||||||
|
| `COMPETITIVE_MIN_PATTERN_SAMPLES` | `3` | Minimum samples for pattern matching |
|
||||||
|
| `COMPETITIVE_MAJOR_DECISION_WEIGHT_MULTIPLIER` | `1.3` | Weight multiplier for major decision patterns |
|
||||||
|
| `COMPETITIVE_STALENESS_WINDOW_DAYS` | `180` | Window for staleness decay on competitive signals |
|
||||||
|
| `COMPETITIVE_STALENESS_RECENT_DAYS` | `90` | Days within which signals are considered recent |
|
||||||
|
| `COMPETITIVE_STALENESS_DECAY_PENALTY` | `0.5` | Decay penalty for stale competitive signals |
|
||||||
|
| `COMPETITIVE_PROPAGATION_FAILURE_THRESHOLD` | `5` | Consecutive propagation failures before operator alert |
|
||||||
|
| `ALERT_SOURCE_FAILURE_THRESHOLD` | `3` | Consecutive source failures before alert fires |
|
||||||
|
| `ALERT_SOURCE_FAILURE_WINDOW_HOURS` | `6` | Lookback window for source failure alerting |
|
||||||
|
| `ALERT_SCHEMA_FAILURE_RATE_THRESHOLD` | `0.3` | Extraction failure rate (30%) that triggers alert |
|
||||||
|
| `ALERT_SCHEMA_FAILURE_WINDOW_HOURS` | `1` | Lookback window for schema failure spike |
|
||||||
|
| `ALERT_LAKE_LAG_THRESHOLD_MINUTES` | `60` | Minutes since last lake publish before alert |
|
||||||
|
| `ALERT_BROKER_ERROR_THRESHOLD` | `3` | Consecutive broker errors before alert |
|
||||||
|
| `ALERT_BROKER_ERROR_WINDOW_HOURS` | `1` | Lookback window for broker error alerting |
|
||||||
|
| `ALERT_CHECK_INTERVAL_SECONDS` | `120` | How often alerting rules are evaluated |
|
||||||
|
| `RETENTION_RAW_MARKET_DAYS` | `90` | Retention period for raw market data (days) |
|
||||||
|
| `RETENTION_RAW_NEWS_DAYS` | `180` | Retention period for raw news articles (days) |
|
||||||
|
| `RETENTION_RAW_FILINGS_DAYS` | `365` | Retention period for raw SEC filings (days) |
|
||||||
|
| `RETENTION_NORMALIZED_DAYS` | `180` | Retention period for normalized documents (days) |
|
||||||
|
| `RETENTION_LLM_PROMPTS_DAYS` | `365` | Retention period for LLM prompt archives (days) |
|
||||||
|
| `RETENTION_LLM_RESULTS_DAYS` | `365` | Retention period for LLM extraction results (days) |
|
||||||
|
| `RETENTION_LAKEHOUSE_DAYS` | `730` | Retention period for lakehouse Parquet files (days) |
|
||||||
|
| `RETENTION_AUDIT_DAYS` | `730` | Retention period for audit trail artifacts (days) |
|
||||||
|
| `RETENTION_CLEANUP_INTERVAL_HOURS` | `24` | How often the retention cleanup worker runs |
|
||||||
|
| `RETENTION_BATCH_SIZE` | `1000` | Number of objects processed per cleanup batch |
|
||||||
| `LOG_LEVEL` | `INFO` | Logging level |
|
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||||
| `JSON_LOGS` | `true` | Enable structured JSON logging |
|
| `JSON_LOGS` | `true` | Enable structured JSON logging |
|
||||||
| `DEPLOY_STAGE` | (empty) | Deployment stage prefix for bucket names |
|
| `DEPLOY_STAGE` | (empty) | Deployment stage prefix for bucket names |
|
||||||
| `TZ` | `America/Los_Angeles` | Display timezone for timestamps (set on all containers) |
|
|
||||||
|
|
||||||
See `services/shared/config.py` for the complete list of all supported environment variables with their defaults.
|
See `services/shared/config.py` for the complete list of all supported environment variables with their defaults.
|
||||||
|
|
||||||
@@ -217,7 +320,7 @@ Stonks Oracle supports two LLM backends: **Ollama** (local, self-hosted) and **v
|
|||||||
|
|
||||||
### Option A: Bundled Ollama (default)
|
### Option A: Bundled Ollama (default)
|
||||||
|
|
||||||
The `docker-compose.yml` includes an Ollama container. On first start, pull a model:
|
The `docker-compose.yml` includes an Ollama container with GPU passthrough via the NVIDIA Container Toolkit. On first start, pull a model:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec ollama ollama pull qwen3.5:9b-fast
|
docker compose exec ollama ollama pull qwen3.5:9b-fast
|
||||||
@@ -225,6 +328,8 @@ docker compose exec ollama ollama pull qwen3.5:9b-fast
|
|||||||
|
|
||||||
No additional configuration needed — services connect to `http://ollama:11434` by default.
|
No additional configuration needed — services connect to `http://ollama:11434` by default.
|
||||||
|
|
||||||
|
The Ollama container requests all available NVIDIA GPUs via the `deploy.resources.reservations.devices` configuration. If no GPU is available, Ollama falls back to CPU inference (significantly slower).
|
||||||
|
|
||||||
### Option B: External Ollama
|
### Option B: External Ollama
|
||||||
|
|
||||||
If Ollama is already running on the host (e.g. with GPU access), create a `docker-compose.override.yml`:
|
If Ollama is already running on the host (e.g. with GPU access), create a `docker-compose.override.yml`:
|
||||||
@@ -252,15 +357,15 @@ services:
|
|||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
```
|
```
|
||||||
|
|
||||||
This disables the bundled Ollama container and routes services to the host's instance. Replace the port if your Ollama runs on a non-standard port.
|
This disables the bundled Ollama container and routes services to the host's instance. Replace the port if your Ollama runs on a non-standard port. For a remote Ollama instance (not on localhost), replace `host.docker.internal` with the remote IP and remove the `extra_hosts` block.
|
||||||
|
|
||||||
### Option C: vLLM Server
|
### Option C: vLLM Server
|
||||||
|
|
||||||
For higher throughput or quantized models (e.g. `AxionML/Qwen3.5-9B-NVFP4`), point services at a vLLM server. Add to your `.env`:
|
For higher throughput or quantized models (e.g. `RedHatAI/Qwen3.6-35B-A3B-NVFP4`), point services at a vLLM server. Add to your `.env`:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
VLLM_BASE_URL=http://192.168.42.254:8000
|
VLLM_BASE_URL=http://192.168.42.254:8000
|
||||||
VLLM_MODEL=AxionML/Qwen3.5-9B-NVFP4
|
VLLM_MODEL=RedHatAI/Qwen3.6-35B-A3B-NVFP4
|
||||||
VLLM_TIMEOUT=120
|
VLLM_TIMEOUT=120
|
||||||
VLLM_TEMPERATURE=0.7
|
VLLM_TEMPERATURE=0.7
|
||||||
```
|
```
|
||||||
@@ -268,7 +373,7 @@ VLLM_TEMPERATURE=0.7
|
|||||||
Then update the `ai_agents` table to use the vLLM provider:
|
Then update the `ai_agents` table to use the vLLM provider:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
UPDATE ai_agents SET model_provider = 'vllm', model_name = 'AxionML/Qwen3.5-9B-NVFP4' WHERE active = true;
|
UPDATE ai_agents SET model_provider = 'vllm', model_name = 'RedHatAI/Qwen3.6-35B-A3B-NVFP4' WHERE active = true;
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the API:
|
Or use the API:
|
||||||
@@ -276,7 +381,7 @@ Or use the API:
|
|||||||
```bash
|
```bash
|
||||||
curl -X PUT http://localhost:8004/api/admin/agents/document-extractor \
|
curl -X PUT http://localhost:8004/api/admin/agents/document-extractor \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"model_provider": "vllm", "model_name": "AxionML/Qwen3.5-9B-NVFP4"}'
|
-d '{"model_provider": "vllm", "model_name": "RedHatAI/Qwen3.6-35B-A3B-NVFP4"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option D: Mixed (Ollama + vLLM)
|
### Option D: Mixed (Ollama + vLLM)
|
||||||
@@ -284,8 +389,8 @@ curl -X PUT http://localhost:8004/api/admin/agents/document-extractor \
|
|||||||
You can run different agents on different providers. For example, use vLLM for the high-volume extractor and Ollama for the thesis rewriter:
|
You can run different agents on different providers. For example, use vLLM for the high-volume extractor and Ollama for the thesis rewriter:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
UPDATE ai_agents SET model_provider = 'vllm', model_name = 'AxionML/Qwen3.5-9B-NVFP4' WHERE slug = 'document-extractor';
|
UPDATE ai_agents SET model_provider = 'vllm', model_name = 'RedHatAI/Qwen3.6-35B-A3B-NVFP4' WHERE slug = 'document-extractor';
|
||||||
UPDATE ai_agents SET model_provider = 'vllm', model_name = 'AxionML/Qwen3.5-9B-NVFP4' WHERE slug = 'event-classifier';
|
UPDATE ai_agents SET model_provider = 'vllm', model_name = 'RedHatAI/Qwen3.6-35B-A3B-NVFP4' WHERE slug = 'event-classifier';
|
||||||
UPDATE ai_agents SET model_provider = 'ollama', model_name = 'qwen3.5:9b-fast' WHERE slug = 'thesis-rewriter';
|
UPDATE ai_agents SET model_provider = 'ollama', model_name = 'qwen3.5:9b-fast' WHERE slug = 'thesis-rewriter';
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -293,19 +398,21 @@ Both `OLLAMA_BASE_URL` and `VLLM_BASE_URL` must be set in the environment for mi
|
|||||||
|
|
||||||
### Automated Deployment
|
### Automated Deployment
|
||||||
|
|
||||||
The `deploy-docker.sh` script handles LLM configuration automatically:
|
The `deploy-docker.sh` script handles LLM configuration automatically. It always uses the Docker Ollama container with GPU passthrough (NVIDIA Container Toolkit):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Auto-detect host Ollama, use default model
|
# Deploy with defaults (Docker Ollama, GPU-accelerated)
|
||||||
bash deploy-docker.sh
|
bash deploy-docker.sh
|
||||||
|
|
||||||
# Specify a remote Ollama instance
|
# Specify a custom model
|
||||||
bash deploy-docker.sh --ollama-url http://10.1.1.12:2701 --ollama-model qwen3.6
|
bash deploy-docker.sh --ollama-model qwen3.6
|
||||||
|
|
||||||
# Specify a different host
|
# Specify a different host and directory
|
||||||
bash deploy-docker.sh --host user@myserver --dir /opt/stonks
|
bash deploy-docker.sh --host user@myserver --dir /opt/stonks
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If an external Ollama URL is provided via `--ollama-url`, the script creates a `docker-compose.override.yml` that disables the bundled container and routes services to the external instance.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Volume Mounts and Data Persistence
|
## Volume Mounts and Data Persistence
|
||||||
@@ -404,6 +511,9 @@ docker compose ps query-api
|
|||||||
|
|
||||||
# Inspect health check details for a container
|
# Inspect health check details for a container
|
||||||
docker inspect --format='{{json .State.Health}}' stonks-oracle-query-api-1 | python -m json.tool
|
docker inspect --format='{{json .State.Health}}' stonks-oracle-query-api-1 | python -m json.tool
|
||||||
|
|
||||||
|
# Wait for all services to be healthy
|
||||||
|
docker compose up -d --wait
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -414,17 +524,19 @@ docker inspect --format='{{json .State.Health}}' stonks-oracle-query-api-1 | pyt
|
|||||||
|
|
||||||
Used by all application services except the scheduler. Accepts a `SERVICE_CMD` build argument that determines which service the container runs.
|
Used by all application services except the scheduler. Accepts a `SERVICE_CMD` build argument that determines which service the container runs.
|
||||||
|
|
||||||
**Base image**: `python:3.12-slim`
|
**Base image**: `python:3.12-slim` (via Harbor proxy cache in CI)
|
||||||
|
|
||||||
**Build arguments**:
|
**Build arguments**:
|
||||||
|
|
||||||
| Argument | Default | Description |
|
| Argument | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `SERVICE_CMD` | `python -m services.scheduler.app` | The command executed when the container starts |
|
| `SERVICE_CMD` | `python -m services.scheduler.app` | The command executed when the container starts |
|
||||||
|
| `CACHE_BUST` | (none) | Optional cache-busting argument to force rebuild of source layers |
|
||||||
|
|
||||||
**What gets copied**:
|
**What gets copied**:
|
||||||
- `requirements.txt` → pip dependencies installed
|
- `requirements.txt` → pip dependencies installed
|
||||||
- `services/` → all service source code
|
- `services/` → all service source code
|
||||||
|
- `scripts/` → operational scripts
|
||||||
- `tests/` → test files (available for in-container testing)
|
- `tests/` → test files (available for in-container testing)
|
||||||
- `conftest.py` → pytest configuration
|
- `conftest.py` → pytest configuration
|
||||||
|
|
||||||
@@ -462,7 +574,7 @@ A specialized variant of the generic Dockerfile used only by the `scheduler` ser
|
|||||||
|
|
||||||
Extends the official Apache Superset image with additional database drivers.
|
Extends the official Apache Superset image with additional database drivers.
|
||||||
|
|
||||||
**Base image**: `apache/superset:latest`
|
**Base image**: `apache/superset:latest` (via Harbor proxy cache in CI)
|
||||||
|
|
||||||
**Additional packages**: `trino[sqlalchemy]`, `psycopg2-binary`, `redis`
|
**Additional packages**: `trino[sqlalchemy]`, `psycopg2-binary`, `redis`
|
||||||
|
|
||||||
@@ -481,7 +593,9 @@ Multi-stage build for the React dashboard.
|
|||||||
**Stage 2 — Serve** (base: `nginxinc/nginx-unprivileged:alpine`):
|
**Stage 2 — Serve** (base: `nginxinc/nginx-unprivileged:alpine`):
|
||||||
- Serves the built static files on port 8080
|
- Serves the built static files on port 8080
|
||||||
- Uses `frontend/nginx.conf` for SPA fallback and API reverse proxying
|
- Uses `frontend/nginx.conf` for SPA fallback and API reverse proxying
|
||||||
- Proxies `/api/` → `query-api:8000`, `/registry/` → `symbol-registry:8000`, `/risk/` → `risk-engine:8000`, `/trading/` → `trading-engine:8000`
|
- Proxies `/api/` → `query-api:8000`, `/registry/` → `symbol-registry:8000`, `/risk/` → `risk:8000`, `/trading/` → `trading-engine:8000`
|
||||||
|
- SSE stream endpoint (`/api/ops/pipeline/stream`) has buffering disabled for real-time delivery
|
||||||
|
- Static assets under `/assets/` are cached with 1-year expiry
|
||||||
|
|
||||||
### Building Custom Images
|
### Building Custom Images
|
||||||
|
|
||||||
@@ -503,6 +617,9 @@ docker build -t my-dashboard \
|
|||||||
|
|
||||||
# Rebuild all images
|
# Rebuild all images
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
|
# Rebuild without cache (force fresh build)
|
||||||
|
docker compose build --no-cache
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -561,6 +678,9 @@ Services with `condition: service_healthy` wait until the dependency's health ch
|
|||||||
# Start all services in the background
|
# Start all services in the background
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
|
# Start all services and wait for health checks
|
||||||
|
docker compose up -d --wait
|
||||||
|
|
||||||
# Start only infrastructure (useful for local development)
|
# Start only infrastructure (useful for local development)
|
||||||
docker compose up -d postgres redis minio minio-init ollama
|
docker compose up -d postgres redis minio minio-init ollama
|
||||||
|
|
||||||
@@ -639,6 +759,9 @@ docker compose exec query-api python -c "from services.shared.config import load
|
|||||||
|
|
||||||
# Open a shell in a container
|
# Open a shell in a container
|
||||||
docker compose exec postgres psql -U stonks -d stonks
|
docker compose exec postgres psql -U stonks -d stonks
|
||||||
|
|
||||||
|
# Seed the database
|
||||||
|
docker compose exec scheduler python -m services.symbol_registry.seed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Full Reset
|
### Full Reset
|
||||||
@@ -680,13 +803,16 @@ The dashboard container runs nginx with reverse proxy rules that route API reque
|
|||||||
| Path | Proxied To | Service |
|
| Path | Proxied To | Service |
|
||||||
|------|-----------|---------|
|
|------|-----------|---------|
|
||||||
| `/api/` | `http://query-api:8000` | Query API |
|
| `/api/` | `http://query-api:8000` | Query API |
|
||||||
|
| `/api/ops/pipeline/stream` | `http://query-api:8000` (SSE, no buffering) | Query API (real-time pipeline stream) |
|
||||||
| `/registry/` | `http://symbol-registry:8000/` | Symbol Registry API |
|
| `/registry/` | `http://symbol-registry:8000/` | Symbol Registry API |
|
||||||
| `/risk/` | `http://risk:8000/` | Risk Engine (via network alias) |
|
| `/risk/` | `http://risk:8000/` | Risk Engine (via network alias) |
|
||||||
| `/trading/` | `http://trading-engine:8000/` | Trading Engine API |
|
| `/trading/` | `http://trading-engine:8000/` | Trading Engine API |
|
||||||
|
|
||||||
The `risk-engine` service has a network alias of `risk` in `docker-compose.yml` so the nginx upstream resolves correctly.
|
The `risk-engine` service has a network alias of `risk` in `docker-compose.yml` so the nginx upstream resolves correctly.
|
||||||
|
|
||||||
All other paths serve the React SPA with `try_files` fallback to `index.html`.
|
All other paths serve the React SPA with `try_files` fallback to `index.html`. Static assets under `/assets/` are served with 1-year cache headers.
|
||||||
|
|
||||||
|
Security headers applied: `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -734,6 +860,19 @@ curl http://your-vllm-host:8000/v1/models
|
|||||||
|
|
||||||
If Ollama is already running on the host, the bundled container will fail to bind port 11434. Use the external Ollama configuration described in the "LLM Provider Configuration" section above, or use `deploy-docker.sh` which handles this automatically.
|
If Ollama is already running on the host, the bundled container will fail to bind port 11434. Use the external Ollama configuration described in the "LLM Provider Configuration" section above, or use `deploy-docker.sh` which handles this automatically.
|
||||||
|
|
||||||
|
### GPU not detected by Ollama container
|
||||||
|
|
||||||
|
Ensure the NVIDIA Container Toolkit is installed and Docker is configured:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify GPU passthrough works
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.8.0-base-ubuntu24.04 nvidia-smi
|
||||||
|
|
||||||
|
# If it fails, reconfigure Docker runtime
|
||||||
|
sudo nvidia-ctk runtime configure --runtime=docker
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
### Port conflicts
|
### Port conflicts
|
||||||
|
|
||||||
If a port is already in use, modify the host port mapping in `docker-compose.yml`:
|
If a port is already in use, modify the host port mapping in `docker-compose.yml`:
|
||||||
@@ -743,3 +882,15 @@ query-api:
|
|||||||
ports:
|
ports:
|
||||||
- "9004:8000" # Changed from 8004 to 9004
|
- "9004:8000" # Changed from 8004 to 9004
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Container runs out of memory
|
||||||
|
|
||||||
|
The full stack requires at least 16 GB RAM. If services are being OOM-killed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check which containers are using the most memory
|
||||||
|
docker stats --no-stream
|
||||||
|
|
||||||
|
# Reduce memory usage by stopping non-essential services
|
||||||
|
docker compose stop trino hive-metastore superset
|
||||||
|
```
|
||||||
+48
-31
@@ -94,7 +94,7 @@ Each key under `services` defines a Kubernetes Deployment. The deployments templ
|
|||||||
| `image` | string | yes | Image name appended to `image.registry`. Also used as the Deployment name and pod label (`app: <image>`). |
|
| `image` | string | yes | Image name appended to `image.registry`. Also used as the Deployment name and pod label (`app: <image>`). |
|
||||||
| `command` | string | no | Shell command passed as `["sh", "-c", "<command>"]`. Omit for images with a built-in entrypoint (e.g., dashboard/nginx). |
|
| `command` | string | no | Shell command passed as `["sh", "-c", "<command>"]`. Omit for images with a built-in entrypoint (e.g., dashboard/nginx). |
|
||||||
| `tier` | string | yes | Service tier label (`stonks-oracle/tier`). One of: `api`, `frontend`, `processing`, `trading`, `orchestration`, `analytics`, `ingestion`. |
|
| `tier` | string | yes | Service tier label (`stonks-oracle/tier`). One of: `api`, `frontend`, `processing`, `trading`, `orchestration`, `analytics`, `ingestion`. |
|
||||||
| `port` | int | no | Container port. When set, a Kubernetes Service is created mapping `port → port`. |
|
| `port` | int | no | Container port. When set, a Kubernetes Service is created mapping `port -> port`. |
|
||||||
| `pipeline` | bool | no | If `true`, replicas are set to 0 when `pipelineEnabled` is `false`. |
|
| `pipeline` | bool | no | If `true`, replicas are set to 0 when `pipelineEnabled` is `false`. |
|
||||||
| `secrets` | list(string) | no | List of Secret names to mount via `envFrom.secretRef`. |
|
| `secrets` | list(string) | no | List of Secret names to mount via `envFrom.secretRef`. |
|
||||||
| `resources` | object | yes | Kubernetes resource requests and limits (`cpu`, `memory`). |
|
| `resources` | object | yes | Kubernetes resource requests and limits (`cpu`, `memory`). |
|
||||||
@@ -118,9 +118,10 @@ Each key under `services` defines a Kubernetes Deployment. The deployments templ
|
|||||||
| `resources.limits` | cpu: 200m, memory: 128Mi |
|
| `resources.limits` | cpu: 200m, memory: 128Mi |
|
||||||
| `probes` | — |
|
| `probes` | — |
|
||||||
|
|
||||||
The scheduler deployment has two init containers (not configurable via values):
|
The scheduler deployment has three init containers (not configurable via values):
|
||||||
1. **run-migrations** — applies all SQL files from `infra/migrations/*.sql` in sorted order.
|
1. **run-migrations** — applies all SQL files from `infra/migrations/*.sql` in sorted order.
|
||||||
2. **seed-if-empty** — runs `python -m services.symbol_registry.seed` if the `companies` table is empty.
|
2. **seed-if-empty** — runs `python -m services.symbol_registry.seed` if the `companies` table is empty.
|
||||||
|
3. **backfill-market-data** — runs `scripts/backfill_market_data.py` if available (skips gracefully if not).
|
||||||
|
|
||||||
#### symbolRegistry
|
#### symbolRegistry
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ The scheduler deployment has two init containers (not configurable via values):
|
|||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| `replicas` | `2` |
|
| `replicas` | `1` |
|
||||||
| `pipeline` | `true` |
|
| `pipeline` | `true` |
|
||||||
| `image` | `ingestion` |
|
| `image` | `ingestion` |
|
||||||
| `command` | `python -m services.ingestion.worker` |
|
| `command` | `python -m services.ingestion.worker` |
|
||||||
@@ -274,7 +275,7 @@ Single replica is recommended — the extractor is bottlenecked by the shared Ol
|
|||||||
| `command` | `uvicorn services.api.app:app --host 0.0.0.0 --port 8000` |
|
| `command` | `uvicorn services.api.app:app --host 0.0.0.0 --port 8000` |
|
||||||
| `tier` | `api` |
|
| `tier` | `api` |
|
||||||
| `port` | `8000` |
|
| `port` | `8000` |
|
||||||
| `secrets` | `stonks-core-secrets` |
|
| `secrets` | `stonks-core-secrets`, `stonks-market-secrets` |
|
||||||
| `resources.requests` | cpu: 100m, memory: 128Mi |
|
| `resources.requests` | cpu: 100m, memory: 128Mi |
|
||||||
| `resources.limits` | cpu: 500m, memory: 256Mi |
|
| `resources.limits` | cpu: 500m, memory: 256Mi |
|
||||||
| `probes.readiness` | path: `/docs`, port: 8000, initialDelay: 5s, period: 10s |
|
| `probes.readiness` | path: `/docs`, port: 8000, initialDelay: 5s, period: 10s |
|
||||||
@@ -323,7 +324,7 @@ All keys under `config` are rendered into a Kubernetes ConfigMap named `stonks-c
|
|||||||
|
|
||||||
| Key | Type | Default | Description |
|
| Key | Type | Default | Description |
|
||||||
|-----|------|---------|-------------|
|
|-----|------|---------|-------------|
|
||||||
| `config.OLLAMA_BASE_URL` | string | `""` (empty) | Ollama API base URL. Set to the cluster-internal or external Ollama endpoint. |
|
| `config.OLLAMA_BASE_URL` | string | `http://10.1.1.12:2701` | Ollama API base URL. Points to the external Ollama endpoint by default. |
|
||||||
| `config.OLLAMA_MODEL` | string | `qwen3.5:9b-fast` | Default LLM model for extraction and classification agents. |
|
| `config.OLLAMA_MODEL` | string | `qwen3.5:9b-fast` | Default LLM model for extraction and classification agents. |
|
||||||
| `config.OLLAMA_TIMEOUT` | string | `240` | Request timeout in seconds for Ollama API calls. |
|
| `config.OLLAMA_TIMEOUT` | string | `240` | Request timeout in seconds for Ollama API calls. |
|
||||||
| `config.OLLAMA_MAX_RETRIES` | string | `2` | Maximum retry attempts for failed Ollama requests. |
|
| `config.OLLAMA_MAX_RETRIES` | string | `2` | Maximum retry attempts for failed Ollama requests. |
|
||||||
@@ -331,6 +332,17 @@ All keys under `config` are rendered into a Kubernetes ConfigMap named `stonks-c
|
|||||||
| `config.OLLAMA_RETRY_MAX_DELAY` | string | `10.0` | Maximum delay cap in seconds for Ollama retry backoff. |
|
| `config.OLLAMA_RETRY_MAX_DELAY` | string | `10.0` | Maximum delay cap in seconds for Ollama retry backoff. |
|
||||||
| `config.OLLAMA_RETRY_BACKOFF_MULTIPLIER` | string | `2.0` | Multiplier for exponential backoff between Ollama retries. |
|
| `config.OLLAMA_RETRY_BACKOFF_MULTIPLIER` | string | `2.0` | Multiplier for exponential backoff between Ollama retries. |
|
||||||
|
|
||||||
|
### vLLM
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `config.VLLM_BASE_URL` | string | `http://10.1.1.12:2701` | vLLM API base URL. Alternative LLM backend using OpenAI-compatible API. |
|
||||||
|
| `config.VLLM_MODEL` | string | `qwen3.5:9b-fast` | vLLM model identifier. |
|
||||||
|
| `config.VLLM_TIMEOUT` | string | `120` | Request timeout in seconds for vLLM API calls. |
|
||||||
|
| `config.VLLM_MAX_RETRIES` | string | `2` | Maximum retry attempts for failed vLLM requests. |
|
||||||
|
| `config.VLLM_TEMPERATURE` | string | `0.7` | Sampling temperature for vLLM generation (0.0-1.0). |
|
||||||
|
| `config.VLLM_API_KEY` | string | `""` (empty) | API key for vLLM authentication. Leave empty if not required. |
|
||||||
|
|
||||||
### Analytics / Trino
|
### Analytics / Trino
|
||||||
|
|
||||||
| Key | Type | Default | Description |
|
| Key | Type | Default | Description |
|
||||||
@@ -347,7 +359,7 @@ All keys under `config` are rendered into a Kubernetes ConfigMap named `stonks-c
|
|||||||
|-----|------|---------|-------------|
|
|-----|------|---------|-------------|
|
||||||
| `config.BROKER_MODE` | string | `paper` | Broker execution mode. `paper` for simulated trading, `live` for real orders. |
|
| `config.BROKER_MODE` | string | `paper` | Broker execution mode. `paper` for simulated trading, `live` for real orders. |
|
||||||
| `config.BROKER_PROVIDER` | string | `""` (empty) | Broker provider name (e.g., `alpaca`). |
|
| `config.BROKER_PROVIDER` | string | `""` (empty) | Broker provider name (e.g., `alpaca`). |
|
||||||
| `config.MARKET_DATA_BASE_URL` | string | `""` (empty) | Market data API base URL (e.g., `https://api.polygon.io`). |
|
| `config.MARKET_DATA_BASE_URL` | string | `https://api.polygon.io` | Market data API base URL. |
|
||||||
| `config.MARKET_DATA_PROVIDER` | string | `polygon` | Market data provider identifier. |
|
| `config.MARKET_DATA_PROVIDER` | string | `polygon` | Market data provider identifier. |
|
||||||
| `config.TRADING_ENABLED` | string | `true` | Master toggle for the trading engine. Set to `false` to disable order submission. |
|
| `config.TRADING_ENABLED` | string | `true` | Master toggle for the trading engine. Set to `false` to disable order submission. |
|
||||||
| `config.TRADING_RISK_TIER` | string | `moderate` | Default risk tier for position sizing. Options: `conservative`, `moderate`, `aggressive`. |
|
| `config.TRADING_RISK_TIER` | string | `moderate` | Default risk tier for position sizing. Options: `conservative`, `moderate`, `aggressive`. |
|
||||||
@@ -384,7 +396,7 @@ All keys under `config` are rendered into a Kubernetes ConfigMap named `stonks-c
|
|||||||
|-----|------|---------|-------------|
|
|-----|------|---------|-------------|
|
||||||
| `config.ALERT_SOURCE_FAILURE_THRESHOLD` | string | `3` | Number of consecutive source failures before firing an alert. |
|
| `config.ALERT_SOURCE_FAILURE_THRESHOLD` | string | `3` | Number of consecutive source failures before firing an alert. |
|
||||||
| `config.ALERT_SOURCE_FAILURE_WINDOW_HOURS` | string | `6` | Time window (hours) for evaluating source failure count. |
|
| `config.ALERT_SOURCE_FAILURE_WINDOW_HOURS` | string | `6` | Time window (hours) for evaluating source failure count. |
|
||||||
| `config.ALERT_SCHEMA_FAILURE_RATE_THRESHOLD` | string | `0.3` | Schema validation failure rate (0.0–1.0) that triggers an alert. |
|
| `config.ALERT_SCHEMA_FAILURE_RATE_THRESHOLD` | string | `0.3` | Schema validation failure rate (0.0-1.0) that triggers an alert. |
|
||||||
| `config.ALERT_SCHEMA_FAILURE_WINDOW_HOURS` | string | `1` | Time window (hours) for evaluating schema failure rate. |
|
| `config.ALERT_SCHEMA_FAILURE_WINDOW_HOURS` | string | `1` | Time window (hours) for evaluating schema failure rate. |
|
||||||
| `config.ALERT_LAKE_LAG_THRESHOLD_MINUTES` | string | `60` | Minutes of lakehouse publish lag before alerting. |
|
| `config.ALERT_LAKE_LAG_THRESHOLD_MINUTES` | string | `60` | Minutes of lakehouse publish lag before alerting. |
|
||||||
| `config.ALERT_BROKER_ERROR_THRESHOLD` | string | `3` | Number of broker errors before firing an alert. |
|
| `config.ALERT_BROKER_ERROR_THRESHOLD` | string | `3` | Number of broker errors before firing an alert. |
|
||||||
@@ -395,7 +407,7 @@ All keys under `config` are rendered into a Kubernetes ConfigMap named `stonks-c
|
|||||||
|
|
||||||
## `secrets` — Kubernetes Secrets
|
## `secrets` — Kubernetes Secrets
|
||||||
|
|
||||||
Secrets are rendered into five Kubernetes Secret objects. In the base `values.yaml`, all secret values default to empty strings. Inject real values at deploy time using `--set` flags or a values override file.
|
Secrets are rendered into five Kubernetes Secret objects. Inject real values at deploy time using `--set` flags or a values override file. The base `values.yaml` contains placeholder values — override them for each environment.
|
||||||
|
|
||||||
### Secret Objects
|
### Secret Objects
|
||||||
|
|
||||||
@@ -403,32 +415,32 @@ Secrets are rendered into five Kubernetes Secret objects. In the base `values.ya
|
|||||||
|-------------|-----------|-------------|
|
|-------------|-----------|-------------|
|
||||||
| `stonks-core-secrets` | `secrets.core` | All services |
|
| `stonks-core-secrets` | `secrets.core` | All services |
|
||||||
| `stonks-broker-secrets` | `secrets.broker` | ingestion, trading-engine, risk-engine, broker-adapter |
|
| `stonks-broker-secrets` | `secrets.broker` | ingestion, trading-engine, risk-engine, broker-adapter |
|
||||||
| `stonks-market-secrets` | `secrets.market` | ingestion |
|
| `stonks-market-secrets` | `secrets.market` | ingestion, query-api |
|
||||||
| `stonks-gmail-secrets` | `secrets.gmail` | trading-engine |
|
| `stonks-gmail-secrets` | `secrets.gmail` | trading-engine |
|
||||||
| `stonks-dashboard-secrets` | `secrets.dashboard` | superset |
|
| `stonks-dashboard-secrets` | `secrets.dashboard` | superset |
|
||||||
|
|
||||||
### `secrets.core`
|
### `secrets.core`
|
||||||
|
|
||||||
| Key | Type | Default | Description |
|
| Key | Type | Description |
|
||||||
|-----|------|---------|-------------|
|
|-----|------|-------------|
|
||||||
| `POSTGRES_PASSWORD` | string | `""` | PostgreSQL password. |
|
| `POSTGRES_PASSWORD` | string | PostgreSQL password. |
|
||||||
| `MINIO_ACCESS_KEY` | string | `""` | MinIO access key (AWS-style). |
|
| `MINIO_ACCESS_KEY` | string | MinIO access key (AWS-style). |
|
||||||
| `MINIO_SECRET_KEY` | string | `""` | MinIO secret key. |
|
| `MINIO_SECRET_KEY` | string | MinIO secret key. |
|
||||||
| `REDIS_PASSWORD` | string | `""` | Redis authentication password. |
|
| `REDIS_PASSWORD` | string | Redis authentication password. |
|
||||||
|
|
||||||
### `secrets.broker`
|
### `secrets.broker`
|
||||||
|
|
||||||
| Key | Type | Default | Description |
|
| Key | Type | Description |
|
||||||
|-----|------|---------|-------------|
|
|-----|------|-------------|
|
||||||
| `BROKER_API_KEY` | string | `""` | Broker API key (e.g., Alpaca paper trading key). |
|
| `BROKER_API_KEY` | string | Broker API key (e.g., Alpaca paper trading key). |
|
||||||
| `BROKER_API_SECRET` | string | `""` | Broker API secret. |
|
| `BROKER_API_SECRET` | string | Broker API secret. |
|
||||||
| `BROKER_BASE_URL` | string | `""` | Broker API base URL (e.g., `https://paper-api.alpaca.markets`). |
|
| `BROKER_BASE_URL` | string | Broker API base URL (e.g., `https://paper-api.alpaca.markets`). |
|
||||||
|
|
||||||
### `secrets.market`
|
### `secrets.market`
|
||||||
|
|
||||||
| Key | Type | Default | Description |
|
| Key | Type | Description |
|
||||||
|-----|------|---------|-------------|
|
|-----|------|-------------|
|
||||||
| `MARKET_DATA_API_KEY` | string | `""` | Market data provider API key (e.g., Polygon.io). |
|
| `MARKET_DATA_API_KEY` | string | Market data provider API key (e.g., Polygon.io). |
|
||||||
|
|
||||||
### `secrets.gmail`
|
### `secrets.gmail`
|
||||||
|
|
||||||
@@ -440,10 +452,10 @@ Secrets are rendered into five Kubernetes Secret objects. In the base `values.ya
|
|||||||
|
|
||||||
### `secrets.dashboard`
|
### `secrets.dashboard`
|
||||||
|
|
||||||
| Key | Type | Default | Description |
|
| Key | Type | Description |
|
||||||
|-----|------|---------|-------------|
|
|-----|------|-------------|
|
||||||
| `SUPERSET_SECRET_KEY` | string | `""` | Flask secret key for Superset session encryption. |
|
| `SUPERSET_SECRET_KEY` | string | Flask secret key for Superset session encryption. |
|
||||||
| `SUPERSET_ADMIN_PASSWORD` | string | `""` | Superset admin user password. |
|
| `SUPERSET_ADMIN_PASSWORD` | string | Superset admin user password. |
|
||||||
|
|
||||||
### Injecting Secrets at Deploy Time
|
### Injecting Secrets at Deploy Time
|
||||||
|
|
||||||
@@ -596,15 +608,20 @@ Key overrides:
|
|||||||
| `pipelineEnabled` | `true` | Services deployed (ArgoCD health checks), but pipeline defaults to OFF via `PIPELINE_DEFAULT_OFF`. |
|
| `pipelineEnabled` | `true` | Services deployed (ArgoCD health checks), but pipeline defaults to OFF via `PIPELINE_DEFAULT_OFF`. |
|
||||||
| `config.DEPLOY_STAGE` | `beta` | Isolates Redis keys (`stonks:beta:*`) and MinIO buckets (`beta-stonks-*`). |
|
| `config.DEPLOY_STAGE` | `beta` | Isolates Redis keys (`stonks:beta:*`) and MinIO buckets (`beta-stonks-*`). |
|
||||||
| `config.POSTGRES_DB` | `stonks_beta` | Separate database for beta data. |
|
| `config.POSTGRES_DB` | `stonks_beta` | Separate database for beta data. |
|
||||||
|
| `config.POSTGRES_USER` | `stonks_beta` | Separate database user for beta. |
|
||||||
| `config.REDIS_DB` | `1` | Separate Redis DB index. |
|
| `config.REDIS_DB` | `1` | Separate Redis DB index. |
|
||||||
| `config.LOG_LEVEL` | `DEBUG` | Verbose logging for debugging. |
|
| `config.LOG_LEVEL` | `DEBUG` | Verbose logging for debugging. |
|
||||||
| `config.TRADING_ENABLED` | `false` | Safety net — no order submission in beta. |
|
| `config.TRADING_ENABLED` | `true` | Trading engine active but constrained by paper broker mode. |
|
||||||
| `config.PIPELINE_DEFAULT_OFF` | `true` | Scheduler won't enqueue jobs unless explicitly enabled. |
|
| `config.PIPELINE_DEFAULT_OFF` | `true` | Scheduler won't enqueue jobs unless explicitly enabled via the UI. |
|
||||||
|
| `config.BROKER_MODE` | `paper` | Simulated order execution. |
|
||||||
|
| `config.BROKER_PROVIDER` | `alpaca` | Alpaca paper trading API. |
|
||||||
| `config.OLLAMA_MODEL` | `qwen3.6` | May use a different model version for testing. |
|
| `config.OLLAMA_MODEL` | `qwen3.6` | May use a different model version for testing. |
|
||||||
| `trino.enabled` | `false` | Analytics stack disabled in beta. |
|
| `trino.enabled` | `false` | Analytics stack disabled in beta. |
|
||||||
| `hiveMetastore.enabled` | `false` | Analytics stack disabled in beta. |
|
| `hiveMetastore.enabled` | `false` | Analytics stack disabled in beta. |
|
||||||
| `superset.enabled` | `false` | Analytics stack disabled in beta. |
|
| `superset.enabled` | `false` | Analytics stack disabled in beta. |
|
||||||
|
|
||||||
|
Beta also configures vLLM settings (`VLLM_BASE_URL`, `VLLM_MODEL`, etc.) for testing alternative LLM backends.
|
||||||
|
|
||||||
Beta ingress hostnames:
|
Beta ingress hostnames:
|
||||||
|
|
||||||
| Service | Hostname |
|
| Service | Hostname |
|
||||||
@@ -649,11 +666,11 @@ Paper ingress hostnames:
|
|||||||
|
|
||||||
```
|
```
|
||||||
values-beta.yaml values-paper.yaml values.yaml (base)
|
values-beta.yaml values-paper.yaml values.yaml (base)
|
||||||
Beta → Paper Trading → Production
|
Beta -> Paper Trading -> Production
|
||||||
Integration Simulated orders Live trading
|
Integration Simulated orders Live trading
|
||||||
testing Real market data Real orders
|
testing Real market data Real orders
|
||||||
Pipeline OFF Pipeline ON Pipeline ON
|
Pipeline OFF Pipeline ON Pipeline ON
|
||||||
Trading OFF Trading ON Trading ON
|
Trading ON Trading ON Trading ON
|
||||||
Analytics OFF Analytics ON Analytics ON
|
Analytics OFF Analytics ON Analytics ON
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ scrape_configs:
|
|||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
scrape_timeout: 10s
|
scrape_timeout: 10s
|
||||||
metrics_path: /metrics
|
metrics_path: /metrics
|
||||||
static_targets:
|
static_configs:
|
||||||
- targets:
|
- targets:
|
||||||
# Docker Compose
|
# Docker Compose
|
||||||
- "query-api:8000"
|
- "query-api:8000"
|
||||||
@@ -124,6 +124,7 @@ All metrics are defined in `services/shared/metrics.py`. Metric names use the `s
|
|||||||
| `stonks_orders_rejected_total` | Counter | `reason_category` | Orders rejected before broker submission |
|
| `stonks_orders_rejected_total` | Counter | `reason_category` | Orders rejected before broker submission |
|
||||||
| `stonks_orders_filled_total` | Counter | `side` | Orders filled by broker |
|
| `stonks_orders_filled_total` | Counter | `side` | Orders filled by broker |
|
||||||
| `stonks_orders_duplicates_prevented_total` | Counter | `detected_via` | Duplicate orders prevented by idempotency checks |
|
| `stonks_orders_duplicates_prevented_total` | Counter | `detected_via` | Duplicate orders prevented by idempotency checks |
|
||||||
|
| `stonks_orders_clamped_total` | Counter | — | Orders auto-clamped to fit within position limits |
|
||||||
| `stonks_risk_evaluations_total` | Counter | `result` | Risk evaluations performed |
|
| `stonks_risk_evaluations_total` | Counter | `result` | Risk evaluations performed |
|
||||||
| `stonks_risk_check_failures_total` | Counter | `check_name` | Individual risk check failures |
|
| `stonks_risk_check_failures_total` | Counter | `check_name` | Individual risk check failures |
|
||||||
| `stonks_positions_synced_total` | Counter | — | Position sync operations completed |
|
| `stonks_positions_synced_total` | Counter | — | Position sync operations completed |
|
||||||
|
|||||||
+117
-10
@@ -41,6 +41,7 @@ All queues use the `stonks:queue:<name>` key pattern (configurable via `DEPLOY_S
|
|||||||
| `recommendation` | `stonks:queue:recommendation` | Aggregation | Recommendation |
|
| `recommendation` | `stonks:queue:recommendation` | Aggregation | Recommendation |
|
||||||
| `broker_orders` | `stonks:queue:broker_orders` | Trading Engine, Trading API | Broker Adapter |
|
| `broker_orders` | `stonks:queue:broker_orders` | Trading Engine, Trading API | Broker Adapter |
|
||||||
| `lake_publish` | `stonks:queue:lake_publish` | Various services | Lake Publisher |
|
| `lake_publish` | `stonks:queue:lake_publish` | Various services | Lake Publisher |
|
||||||
|
| `report_generation` | `stonks:queue:report_generation` | Scheduler | Scheduler (inline consumer) |
|
||||||
|
|
||||||
### Queue Message Schemas
|
### Queue Message Schemas
|
||||||
|
|
||||||
@@ -131,11 +132,20 @@ All queues use the `stonks:queue:<name>` key pattern (configurable via `DEPLOY_S
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Report Generation Job** (`stonks:queue:report_generation`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"report_type": "daily | weekly",
|
||||||
|
"period_start": "2025-01-01",
|
||||||
|
"period_end": "2025-01-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Scheduler
|
## 1. Scheduler
|
||||||
|
|
||||||
**Purpose**: Triggers ingestion cycles for tracked companies and sources on a configurable cadence. Polls the symbol registry for active companies and their configured sources, respects per-source polling intervals and backoff windows, coordinates rate limits across source types, and enqueues ingestion jobs for downstream workers. Also runs periodic maintenance: stale document recovery, failed extraction retries, and data retention cleanup.
|
**Purpose**: Triggers ingestion cycles for tracked companies and sources on a configurable cadence. Polls the symbol registry for active companies and their configured sources, respects per-source polling intervals and backoff windows, coordinates rate limits across source types, and enqueues ingestion jobs for downstream workers. Also runs periodic maintenance: stale document recovery, failed extraction retries, data retention cleanup, periodic aggregation re-runs, and automated report generation (daily/weekly).
|
||||||
|
|
||||||
**Entry Point**: `services.scheduler.app`
|
**Entry Point**: `services.scheduler.app`
|
||||||
|
|
||||||
@@ -176,12 +186,16 @@ All queues use the `stonks:queue:<name>` key pattern (configurable via `DEPLOY_S
|
|||||||
| `recommendations` | Write (delete) | Retention cleanup |
|
| `recommendations` | Write (delete) | Retention cleanup |
|
||||||
| `order_events` | Write (delete) | Retention cleanup |
|
| `order_events` | Write (delete) | Retention cleanup |
|
||||||
| `model_performance_metrics` | Write (delete) | Retention cleanup |
|
| `model_performance_metrics` | Write (delete) | Retention cleanup |
|
||||||
|
| `ingestion_runs` | Write (delete) | Retention cleanup |
|
||||||
|
| `trading_reports` | Write | Report generation storage |
|
||||||
|
|
||||||
### Redis Queues
|
### Redis Queues
|
||||||
|
|
||||||
| Direction | Queue | Purpose |
|
| Direction | Queue | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Publish | `stonks:queue:ingestion` | Enqueue ingestion jobs for due sources |
|
| Publish | `stonks:queue:ingestion` | Enqueue ingestion jobs for due sources |
|
||||||
|
| Publish | `stonks:queue:aggregation` | Periodic aggregation re-runs |
|
||||||
|
| Publish/Consume | `stonks:queue:report_generation` | Enqueue and consume report generation jobs |
|
||||||
| Read | `stonks:pipeline:enabled` | Pipeline toggle (skip cycle if `"0"`) |
|
| Read | `stonks:pipeline:enabled` | Pipeline toggle (skip cycle if `"0"`) |
|
||||||
| Read/Write | `stonks:lock:scheduler_cycle` | Distributed lock for single-writer |
|
| Read/Write | `stonks:lock:scheduler_cycle` | Distributed lock for single-writer |
|
||||||
| Read/Write | `stonks:ratelimit:*` | Per-source-type and global Polygon rate limits |
|
| Read/Write | `stonks:ratelimit:*` | Per-source-type and global Polygon rate limits |
|
||||||
@@ -195,6 +209,8 @@ All queues use the `stonks:queue:<name>` key pattern (configurable via `DEPLOY_S
|
|||||||
- **Stale document recovery**: Every ~5 minutes, re-enqueues documents stuck in `parsed` status for >240 minutes.
|
- **Stale document recovery**: Every ~5 minutes, re-enqueues documents stuck in `parsed` status for >240 minutes.
|
||||||
- **Failed extraction retry**: Every ~10 minutes, re-enqueues `extraction_failed` documents older than 60 minutes.
|
- **Failed extraction retry**: Every ~10 minutes, re-enqueues `extraction_failed` documents older than 60 minutes.
|
||||||
- **Data retention cleanup**: Every ~25 minutes, deletes old rows from 10 tables with configurable retention windows (14–90 days).
|
- **Data retention cleanup**: Every ~25 minutes, deletes old rows from 10 tables with configurable retention windows (14–90 days).
|
||||||
|
- **Periodic aggregation**: Re-enqueues aggregation jobs for all active tickers to keep trend summaries fresh.
|
||||||
|
- **Report generation**: Enqueues daily and weekly report jobs on schedule; consumes them inline via `process_report_job` with retry logic (3 attempts, exponential backoff 30s/60s/120s).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -281,7 +297,7 @@ None — this service is purely HTTP-driven.
|
|||||||
### MinIO Buckets
|
### MinIO Buckets
|
||||||
|
|
||||||
- `stonks-raw-market` — Raw market data JSON
|
- `stonks-raw-market` — Raw market data JSON
|
||||||
- `stonks-raw-news` — Raw news article JSON
|
- `stonks-raw-news` — Raw news article JSON (also used for macro news)
|
||||||
- `stonks-raw-filings` — Raw SEC filing data
|
- `stonks-raw-filings` — Raw SEC filing data
|
||||||
- `stonks-normalized` — Normalized text (written by parser)
|
- `stonks-normalized` — Normalized text (written by parser)
|
||||||
|
|
||||||
@@ -296,6 +312,13 @@ None — this service is purely HTTP-driven.
|
|||||||
| `broker` | `AlpacaBrokerAdapter` | Alpaca |
|
| `broker` | `AlpacaBrokerAdapter` | Alpaca |
|
||||||
| `macro_news` | `MacroNewsAdapter` | Polygon.io |
|
| `macro_news` | `MacroNewsAdapter` | Polygon.io |
|
||||||
|
|
||||||
|
### Key Behaviors
|
||||||
|
|
||||||
|
- Macro news jobs (`source_type=macro_news`) may lack a `company_id` — the worker handles this gracefully
|
||||||
|
- Macro news documents are typed as `macro_event` so the parser routes them to the macro classification queue
|
||||||
|
- Duplicate documents detected via content hash are linked to the current company (except for `macro_news`)
|
||||||
|
- Tracks `last_published_at` per source to fetch only newer articles on subsequent runs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Parser
|
## 4. Parser
|
||||||
@@ -349,7 +372,7 @@ None — this service is purely HTTP-driven.
|
|||||||
|
|
||||||
## 5. Extractor
|
## 5. Extractor
|
||||||
|
|
||||||
**Purpose**: Performs LLM-based intelligence extraction from documents using Ollama. Handles two pipelines: (1) standard document extraction producing `DocumentIntelligence` with per-company impact records, and (2) macro event classification producing `GlobalEventSchema` with company-level macro impact interpolation. Supports AI agent configuration with variant-based A/B testing.
|
**Purpose**: Performs LLM-based intelligence extraction from documents using Ollama or a remote vLLM inference server. Handles two pipelines: (1) standard document extraction producing `DocumentIntelligence` with per-company impact records, and (2) macro event classification producing `GlobalEventSchema` with company-level macro impact interpolation. Supports AI agent configuration with variant-based A/B testing and provider routing (Ollama or vLLM).
|
||||||
|
|
||||||
**Entry Point**: `services.extractor.main`
|
**Entry Point**: `services.extractor.main`
|
||||||
|
|
||||||
@@ -363,9 +386,16 @@ None — this service is purely HTTP-driven.
|
|||||||
| `REDIS_*` | _(see shared)_ | Redis connection |
|
| `REDIS_*` | _(see shared)_ | Redis connection |
|
||||||
| `MINIO_*` | _(see shared)_ | MinIO connection |
|
| `MINIO_*` | _(see shared)_ | MinIO connection |
|
||||||
| `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama API endpoint |
|
| `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama API endpoint |
|
||||||
| `OLLAMA_MODEL` | `qwen3.5:9b` | Default LLM model |
|
| `OLLAMA_MODEL` | `qwen3.5:9b` | Default Ollama model |
|
||||||
| `OLLAMA_TIMEOUT` | `120` | Request timeout (seconds) |
|
| `OLLAMA_TIMEOUT` | `120` | Request timeout (seconds) |
|
||||||
| `OLLAMA_MAX_RETRIES` | `2` | Max retry attempts |
|
| `OLLAMA_MAX_RETRIES` | `2` | Max retry attempts |
|
||||||
|
| `VLLM_BASE_URL` | `http://192.168.42.254:8000` | vLLM inference server endpoint |
|
||||||
|
| `VLLM_MODEL` | `RedHatAI/Qwen3.6-35B-A3B-NVFP4` | Default vLLM model |
|
||||||
|
| `VLLM_TIMEOUT` | `120` | vLLM request timeout (seconds) |
|
||||||
|
| `VLLM_MAX_RETRIES` | `2` | vLLM max retry attempts |
|
||||||
|
| `VLLM_MAX_TOKENS` | `4096` | vLLM max output tokens |
|
||||||
|
| `VLLM_TEMPERATURE` | `0.7` | vLLM sampling temperature |
|
||||||
|
| `VLLM_API_KEY` | _(empty)_ | Optional API key for authenticated vLLM deployments |
|
||||||
| `MACRO_CONFIDENCE_THRESHOLD` | `0.4` | Minimum confidence for macro event inclusion |
|
| `MACRO_CONFIDENCE_THRESHOLD` | `0.4` | Minimum confidence for macro event inclusion |
|
||||||
| `LOG_LEVEL` | `INFO` | Logging level |
|
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||||
|
|
||||||
@@ -395,6 +425,7 @@ None — this service is purely HTTP-driven.
|
|||||||
|
|
||||||
### Key Behaviors
|
### Key Behaviors
|
||||||
|
|
||||||
|
- **LLM provider routing**: The `AgentConfigResolver` resolves agent configuration from the DB, including a `model_provider` field (`"ollama"` or `"vllm"`). The `build_llm_client` factory returns the appropriate client (`OllamaClient` or `VLLMClient`).
|
||||||
- Alternates between macro and extraction queues (1 macro per 3 jobs) to prevent starvation
|
- Alternates between macro and extraction queues (1 macro per 3 jobs) to prevent starvation
|
||||||
- Resolves agent configuration from DB with 60-second TTL cache (`AgentConfigResolver`)
|
- Resolves agent configuration from DB with 60-second TTL cache (`AgentConfigResolver`)
|
||||||
- Supports separate models for document extraction and event classification
|
- Supports separate models for document extraction and event classification
|
||||||
@@ -565,7 +596,7 @@ None — this service is purely HTTP-driven.
|
|||||||
| `risk_tier_history` | Read/Write | Risk tier change audit trail |
|
| `risk_tier_history` | Read/Write | Risk tier change audit trail |
|
||||||
| `circuit_breaker_events` | Read/Write | Circuit breaker trigger/reset events |
|
| `circuit_breaker_events` | Read/Write | Circuit breaker trigger/reset events |
|
||||||
| `positions` | Read | Current open positions |
|
| `positions` | Read | Current open positions |
|
||||||
| `position_stop_levels` | Read/Write | Stop-loss and take-profit levels |
|
| `position_stop_levels` | Read/Write | Stop-loss and take-profit levels per position |
|
||||||
| `orders` | Read | Order history for dedup |
|
| `orders` | Read | Order history for dedup |
|
||||||
| `backtest_runs` | Read/Write | Backtest configuration and results |
|
| `backtest_runs` | Read/Write | Backtest configuration and results |
|
||||||
| `backtest_trades` | Read/Write | Individual trades within a backtest |
|
| `backtest_trades` | Read/Write | Individual trades within a backtest |
|
||||||
@@ -652,7 +683,7 @@ None — called synchronously by the broker adapter and via HTTP.
|
|||||||
| `positions` | Write (upsert) | Sync positions from Alpaca |
|
| `positions` | Write (upsert) | Sync positions from Alpaca |
|
||||||
| `broker_accounts` | Write (upsert) | Register/update broker account |
|
| `broker_accounts` | Write (upsert) | Register/update broker account |
|
||||||
| `daily_risk_snapshots` | Read | Daily portfolio state for risk evaluation |
|
| `daily_risk_snapshots` | Read | Daily portfolio state for risk evaluation |
|
||||||
| `risk_configs` | Read | Active risk configuration |
|
| `risk_configs` | Read | Active risk configuration for order evaluation |
|
||||||
| `approval_requests` | Write | Create approval requests for gated orders |
|
| `approval_requests` | Write | Create approval requests for gated orders |
|
||||||
| `audit_events` | Write | Full audit trail |
|
| `audit_events` | Write | Full audit trail |
|
||||||
|
|
||||||
@@ -728,7 +759,7 @@ None — called synchronously by the broker adapter and via HTTP.
|
|||||||
|
|
||||||
## 12. Query API
|
## 12. Query API
|
||||||
|
|
||||||
**Purpose**: Read-only FastAPI service for analytics, evidence drill-down, and admin controls. Serves the React dashboard and external integrations with endpoints for companies, documents, trends, recommendations, orders, positions, portfolio metrics, global events, macro impacts, competitive signals, trend projections, AI agents, dead-letter queues, pipeline control, SQL explorer, saved queries, audit trail, DevOps metrics, and Prometheus metrics.
|
**Purpose**: Read-only FastAPI service for analytics, evidence drill-down, and admin controls. Serves the React dashboard and external integrations with endpoints for companies, documents, trends, recommendations, orders, positions, portfolio metrics, global events, macro impacts, competitive signals, trend projections, AI agents, dead-letter queues, pipeline control, SQL explorer, saved queries, audit trail, DevOps metrics, Prometheus metrics, model validation, and trading reports.
|
||||||
|
|
||||||
**Entry Point**: `services.api.app` (FastAPI)
|
**Entry Point**: `services.api.app` (FastAPI)
|
||||||
|
|
||||||
@@ -745,6 +776,7 @@ None — called synchronously by the broker adapter and via HTTP.
|
|||||||
| `TRINO_PORT` | `8080` | Trino port |
|
| `TRINO_PORT` | `8080` | Trino port |
|
||||||
| `TRINO_CATALOG` | `lakehouse` | Trino catalog |
|
| `TRINO_CATALOG` | `lakehouse` | Trino catalog |
|
||||||
| `TRINO_SCHEMA` | `stonks` | Trino schema |
|
| `TRINO_SCHEMA` | `stonks` | Trino schema |
|
||||||
|
| `TRINO_ICEBERG_CATALOG` | `iceberg` | Trino Iceberg catalog |
|
||||||
| `LOG_LEVEL` | `INFO` | Logging level |
|
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||||
|
|
||||||
### Database Tables
|
### Database Tables
|
||||||
@@ -757,9 +789,9 @@ The Query API reads from nearly all tables in the database, including:
|
|||||||
| `sources` | Source configurations |
|
| `sources` | Source configurations |
|
||||||
| `documents`, `document_company_mentions` | Document timelines |
|
| `documents`, `document_company_mentions` | Document timelines |
|
||||||
| `document_intelligence`, `document_impact_records` | Intelligence extraction results |
|
| `document_intelligence`, `document_impact_records` | Intelligence extraction results |
|
||||||
| `trend_windows`, `trend_history`, `trend_projections` | Trend summaries and projections |
|
| `trend_windows`, `trend_history`, `trend_projections`, `trend_evidence` | Trend summaries and projections |
|
||||||
| `recommendations`, `recommendation_evidence` | Recommendation history with evidence |
|
| `recommendations`, `recommendation_evidence` | Recommendation history with evidence |
|
||||||
| `risk_evaluations` | Risk evaluation results |
|
| `risk_evaluations`, `risk_configs` | Risk evaluation results and configuration |
|
||||||
| `orders`, `order_events` | Order history and lifecycle |
|
| `orders`, `order_events` | Order history and lifecycle |
|
||||||
| `positions`, `portfolio_snapshots` | Portfolio state |
|
| `positions`, `portfolio_snapshots` | Portfolio state |
|
||||||
| `global_events`, `macro_impact_records` | Macro event data |
|
| `global_events`, `macro_impact_records` | Macro event data |
|
||||||
@@ -768,6 +800,13 @@ The Query API reads from nearly all tables in the database, including:
|
|||||||
| `audit_events` | Audit trail |
|
| `audit_events` | Audit trail |
|
||||||
| `market_snapshots` | Market price data |
|
| `market_snapshots` | Market price data |
|
||||||
| `watchlists`, `watchlist_members` | Watchlist data |
|
| `watchlists`, `watchlist_members` | Watchlist data |
|
||||||
|
| `ingestion_runs` | Ingestion throughput and source health |
|
||||||
|
| `model_performance_metrics` | Model quality metrics |
|
||||||
|
| `prediction_snapshots`, `prediction_outcomes` | Model validation and calibration |
|
||||||
|
| `trading_decisions` | Trading decision history |
|
||||||
|
| `trading_reports` | Generated daily/weekly reports |
|
||||||
|
| `approval_requests` | Pending approval workflow |
|
||||||
|
| `symbol_lockouts` | Active trading lockouts per symbol |
|
||||||
|
|
||||||
### Redis Queues
|
### Redis Queues
|
||||||
|
|
||||||
@@ -776,15 +815,22 @@ The Query API reads from nearly all tables in the database, including:
|
|||||||
| Read/Write | `stonks:pipeline:enabled` | Pipeline toggle control |
|
| Read/Write | `stonks:pipeline:enabled` | Pipeline toggle control |
|
||||||
| Read | `stonks:queue:*` | Queue depth monitoring for DLQ and DevOps metrics |
|
| Read | `stonks:queue:*` | Queue depth monitoring for DLQ and DevOps metrics |
|
||||||
| Read | `stonks:dlq:*` | Dead-letter queue inspection and replay |
|
| Read | `stonks:dlq:*` | Dead-letter queue inspection and replay |
|
||||||
|
| Read | `stonks:ratelimit:*` | Rate limit status monitoring |
|
||||||
|
|
||||||
### Key Behaviors
|
### Key Behaviors
|
||||||
|
|
||||||
- Exposes `/metrics` endpoint for Prometheus scraping
|
- Exposes `/metrics` endpoint for Prometheus scraping
|
||||||
- Trace context propagation via `x-trace-id` header middleware
|
- Trace context propagation via `x-trace-id` header middleware
|
||||||
- SQL explorer endpoint for ad-hoc Trino queries
|
- SQL explorer endpoint for ad-hoc Trino queries (`/analytics/query`)
|
||||||
|
- PostgreSQL schema explorer (`/pg/schema`, `/pg/query`)
|
||||||
- Dead-letter queue management (list, inspect, replay)
|
- Dead-letter queue management (list, inspect, replay)
|
||||||
- Pipeline control (enable/disable via Redis toggle)
|
- Pipeline control (enable/disable via Redis toggle)
|
||||||
- Saved queries with CRUD operations
|
- Saved queries with CRUD operations
|
||||||
|
- Macro and competitive layer toggle endpoints
|
||||||
|
- Model validation endpoints (summary, calibration, IC by horizon, gate status, attribution)
|
||||||
|
- Trading report listing and retrieval
|
||||||
|
- SSE pipeline health stream (`/pipeline/stream`)
|
||||||
|
- Market price backfill endpoints
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1042,6 +1088,67 @@ All services load configuration from environment variables via `services/shared/
|
|||||||
| `OLLAMA_MODEL` | `qwen3.5:9b` | Default model |
|
| `OLLAMA_MODEL` | `qwen3.5:9b` | Default model |
|
||||||
| `OLLAMA_TIMEOUT` | `120` | Request timeout (seconds) |
|
| `OLLAMA_TIMEOUT` | `120` | Request timeout (seconds) |
|
||||||
| `OLLAMA_MAX_RETRIES` | `2` | Max retry attempts |
|
| `OLLAMA_MAX_RETRIES` | `2` | Max retry attempts |
|
||||||
|
| `OLLAMA_RETRY_BASE_DELAY` | `1.0` | Base delay between retries (seconds) |
|
||||||
|
| `OLLAMA_RETRY_MAX_DELAY` | `10.0` | Maximum delay between retries (seconds) |
|
||||||
|
| `OLLAMA_RETRY_BACKOFF_MULTIPLIER` | `2.0` | Backoff multiplier |
|
||||||
|
|
||||||
|
### vLLM
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `VLLM_BASE_URL` | `http://192.168.42.254:8000` | vLLM inference server endpoint |
|
||||||
|
| `VLLM_MODEL` | `RedHatAI/Qwen3.6-35B-A3B-NVFP4` | Default vLLM model |
|
||||||
|
| `VLLM_TIMEOUT` | `120` | Request timeout (seconds) |
|
||||||
|
| `VLLM_MAX_RETRIES` | `2` | Max retry attempts |
|
||||||
|
| `VLLM_MAX_TOKENS` | `4096` | Max output tokens |
|
||||||
|
| `VLLM_TEMPERATURE` | `0.7` | Sampling temperature |
|
||||||
|
| `VLLM_API_KEY` | _(empty)_ | Optional API key for authenticated deployments |
|
||||||
|
| `VLLM_RETRY_BASE_DELAY` | `1.0` | Base delay between retries (seconds) |
|
||||||
|
| `VLLM_RETRY_MAX_DELAY` | `10.0` | Maximum delay between retries (seconds) |
|
||||||
|
| `VLLM_RETRY_BACKOFF_MULTIPLIER` | `2.0` | Backoff multiplier |
|
||||||
|
|
||||||
|
### Trino
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `TRINO_HOST` | `localhost` | Trino host |
|
||||||
|
| `TRINO_PORT` | `8080` | Trino port |
|
||||||
|
| `TRINO_CATALOG` | `lakehouse` | Trino catalog |
|
||||||
|
| `TRINO_SCHEMA` | `stonks` | Trino schema |
|
||||||
|
| `TRINO_ICEBERG_CATALOG` | `iceberg` | Trino Iceberg catalog |
|
||||||
|
|
||||||
|
### Market Data
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `MARKET_DATA_API_KEY` | _(empty)_ | Polygon.io API key |
|
||||||
|
| `MARKET_DATA_BASE_URL` | `https://api.polygon.io` | Polygon base URL |
|
||||||
|
| `MARKET_DATA_PROVIDER` | `polygon` | Market data provider |
|
||||||
|
|
||||||
|
### Broker
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `BROKER_MODE` | `paper` | Trading mode (`paper` or `live`) |
|
||||||
|
| `BROKER_PROVIDER` | `alpaca` | Broker provider |
|
||||||
|
| `BROKER_API_KEY` | _(none)_ | Alpaca API key |
|
||||||
|
| `BROKER_API_SECRET` | _(none)_ | Alpaca API secret |
|
||||||
|
| `BROKER_BASE_URL` | _(none)_ | Alpaca base URL |
|
||||||
|
|
||||||
|
### Retention
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `RETENTION_RAW_MARKET_DAYS` | `90` | Raw market data retention (days) |
|
||||||
|
| `RETENTION_RAW_NEWS_DAYS` | `180` | Raw news data retention (days) |
|
||||||
|
| `RETENTION_RAW_FILINGS_DAYS` | `365` | Raw filings retention (days) |
|
||||||
|
| `RETENTION_NORMALIZED_DAYS` | `180` | Normalized text retention (days) |
|
||||||
|
| `RETENTION_LLM_PROMPTS_DAYS` | `365` | LLM prompt retention (days) |
|
||||||
|
| `RETENTION_LLM_RESULTS_DAYS` | `365` | LLM result retention (days) |
|
||||||
|
| `RETENTION_LAKEHOUSE_DAYS` | `730` | Lakehouse data retention (days) |
|
||||||
|
| `RETENTION_AUDIT_DAYS` | `730` | Audit log retention (days) |
|
||||||
|
| `RETENTION_CLEANUP_INTERVAL_HOURS` | `24` | Cleanup interval (hours) |
|
||||||
|
| `RETENTION_BATCH_SIZE` | `1000` | Rows deleted per batch |
|
||||||
|
|
||||||
### Observability
|
### Observability
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,17 @@ services:
|
|||||||
requests: { cpu: 50m, memory: 64Mi }
|
requests: { cpu: 50m, memory: 64Mi }
|
||||||
limits: { cpu: 200m, memory: 128Mi }
|
limits: { cpu: 200m, memory: 128Mi }
|
||||||
|
|
||||||
|
signalEngine:
|
||||||
|
replicas: 1
|
||||||
|
pipeline: true
|
||||||
|
image: signal-engine
|
||||||
|
command: "python -m services.signal_engine.main"
|
||||||
|
tier: processing
|
||||||
|
secrets: [stonks-core-secrets, stonks-market-secrets]
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 100m, memory: 128Mi }
|
||||||
|
limits: { cpu: 500m, memory: 256Mi }
|
||||||
|
|
||||||
lakePublisher:
|
lakePublisher:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
pipeline: true
|
pipeline: true
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Migration 039: Signal Engine Outputs
|
||||||
|
-- Creates the signal_engine_outputs table for persisting dual-pipeline evaluations.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS signal_engine_outputs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
evaluated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
price NUMERIC NOT NULL,
|
||||||
|
|
||||||
|
-- Heuristic pipeline
|
||||||
|
heuristic_verdict TEXT NOT NULL,
|
||||||
|
heuristic_confidence NUMERIC NOT NULL,
|
||||||
|
heuristic_s_total NUMERIC NOT NULL,
|
||||||
|
|
||||||
|
-- Probabilistic pipeline
|
||||||
|
probabilistic_verdict TEXT NOT NULL,
|
||||||
|
probabilistic_p_up NUMERIC NOT NULL,
|
||||||
|
probabilistic_entropy NUMERIC NOT NULL,
|
||||||
|
probabilistic_ev_r NUMERIC NOT NULL,
|
||||||
|
|
||||||
|
-- Delta analysis
|
||||||
|
delta_agreement BOOLEAN NOT NULL,
|
||||||
|
delta_confidence_delta NUMERIC NOT NULL,
|
||||||
|
delta_reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- Trade plan (null when no BUY verdict)
|
||||||
|
trade_plan JSONB,
|
||||||
|
|
||||||
|
-- Full output for audit
|
||||||
|
full_output JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Exit signals
|
||||||
|
exit_signals JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
pipeline_mode TEXT NOT NULL DEFAULT 'dual_pipeline',
|
||||||
|
shadow_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for per-ticker time-range queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signal_engine_outputs_ticker_time
|
||||||
|
ON signal_engine_outputs (ticker, evaluated_at);
|
||||||
|
|
||||||
|
-- Index for global time-range queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signal_engine_outputs_evaluated
|
||||||
|
ON signal_engine_outputs (evaluated_at);
|
||||||
|
|
||||||
|
-- Index for filtering by verdict
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signal_engine_outputs_verdicts
|
||||||
|
ON signal_engine_outputs (heuristic_verdict, probabilistic_verdict);
|
||||||
@@ -70,6 +70,7 @@ QUEUE_BROKER = "broker_orders"
|
|||||||
QUEUE_MACRO_CLASSIFICATION = "macro_classification"
|
QUEUE_MACRO_CLASSIFICATION = "macro_classification"
|
||||||
QUEUE_REPORT_GENERATION = "report_generation"
|
QUEUE_REPORT_GENERATION = "report_generation"
|
||||||
QUEUE_REPORT_GENERATION = "report_generation"
|
QUEUE_REPORT_GENERATION = "report_generation"
|
||||||
|
QUEUE_SIGNAL_ENGINE = "signal_engine"
|
||||||
|
|
||||||
# --- Trading engine ---
|
# --- Trading engine ---
|
||||||
QUEUE_TRADING_DECISIONS = "trading_decisions"
|
QUEUE_TRADING_DECISIONS = "trading_decisions"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Signal Engine - dual-pipeline signal evaluation (heuristic + probabilistic)
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""Signal engine configuration loaded from risk_configs + environment.
|
||||||
|
|
||||||
|
Defines ``SignalEngineConfig`` (the top-level dataclass) and four derived
|
||||||
|
sub-configs — ``HardFilterConfig``, ``HeuristicConfig``,
|
||||||
|
``ProbabilisticConfig``, ``ExitConfig`` — that expose relevant subsets for
|
||||||
|
cleaner function signatures.
|
||||||
|
|
||||||
|
``load_config()`` reads from the ``risk_configs`` table's JSONB ``config``
|
||||||
|
column and falls back to safe defaults on any error. Environment variables
|
||||||
|
with the ``SIGNAL_ENGINE_`` prefix override database values.
|
||||||
|
|
||||||
|
Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sub-configs — thin wrappers over relevant subsets of SignalEngineConfig
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HardFilterConfig:
|
||||||
|
"""Thresholds for the pre-pipeline hard filter engine."""
|
||||||
|
|
||||||
|
valuation_min: float = 0.3
|
||||||
|
earnings_days: int = 5
|
||||||
|
macro_bias_skip: float = -1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeuristicConfig:
|
||||||
|
"""Thresholds for the heuristic (deterministic) pipeline verdict."""
|
||||||
|
|
||||||
|
buy_confidence: float = 0.70
|
||||||
|
buy_s_total: float = 1.2
|
||||||
|
buy_valuation_min: float = 0.5
|
||||||
|
watch_confidence: float = 0.55
|
||||||
|
macro_bias_threshold: float = 0.0 # macro_bias must be > this for BUY
|
||||||
|
earnings_days_threshold: int = 5 # earnings_proximity must be > this for BUY
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProbabilisticConfig:
|
||||||
|
"""Thresholds for the probabilistic (Bayesian) pipeline verdict."""
|
||||||
|
|
||||||
|
buy_p_up: float = 0.60
|
||||||
|
buy_entropy_max: float = 0.90
|
||||||
|
buy_ev_r_min: float = 1.5
|
||||||
|
buy_valuation_min: float = 0.5
|
||||||
|
watch_p_up: float = 0.55
|
||||||
|
watch_entropy_max: float = 0.95
|
||||||
|
entropy_skip: float = 0.95
|
||||||
|
|
||||||
|
# Regime priors
|
||||||
|
regime_prior_bull: float = 0.58
|
||||||
|
regime_prior_range: float = 0.50
|
||||||
|
regime_prior_bear: float = 0.42
|
||||||
|
|
||||||
|
# Fundamental gates (same semantics as heuristic)
|
||||||
|
macro_bias_threshold: float = 0.0
|
||||||
|
earnings_days_threshold: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExitConfig:
|
||||||
|
"""Configuration for the exit engine."""
|
||||||
|
|
||||||
|
trailing_stop_atr_multiplier: float = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Top-level config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SignalEngineConfig:
|
||||||
|
"""Configuration loaded from risk_configs + environment.
|
||||||
|
|
||||||
|
All fields carry safe defaults so that a fresh deployment works without
|
||||||
|
any database rows or environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dual_pipeline_enabled: bool = False
|
||||||
|
heuristic_pipeline_enabled: bool = True
|
||||||
|
probabilistic_pipeline_enabled: bool = True
|
||||||
|
shadow_mode: bool = False
|
||||||
|
|
||||||
|
# Timeframe weights
|
||||||
|
timeframe_weights: dict[str, float] = field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"M30": 0.03,
|
||||||
|
"H1": 0.07,
|
||||||
|
"H4": 0.15,
|
||||||
|
"D": 0.30,
|
||||||
|
"W": 0.30,
|
||||||
|
"M": 0.15,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hard filter thresholds
|
||||||
|
hard_filter_valuation_min: float = 0.3
|
||||||
|
hard_filter_earnings_days: int = 5
|
||||||
|
hard_filter_macro_bias_skip: float = -1.0
|
||||||
|
|
||||||
|
# Heuristic verdict thresholds
|
||||||
|
heuristic_buy_confidence: float = 0.70
|
||||||
|
heuristic_buy_s_total: float = 1.2
|
||||||
|
heuristic_buy_valuation_min: float = 0.5
|
||||||
|
heuristic_watch_confidence: float = 0.55
|
||||||
|
|
||||||
|
# Probabilistic verdict thresholds
|
||||||
|
prob_buy_p_up: float = 0.60
|
||||||
|
prob_buy_entropy_max: float = 0.90
|
||||||
|
prob_buy_ev_r_min: float = 1.5
|
||||||
|
prob_buy_valuation_min: float = 0.5
|
||||||
|
prob_watch_p_up: float = 0.55
|
||||||
|
prob_watch_entropy_max: float = 0.95
|
||||||
|
prob_entropy_skip: float = 0.95
|
||||||
|
|
||||||
|
# Regime priors
|
||||||
|
regime_prior_bull: float = 0.58
|
||||||
|
regime_prior_range: float = 0.50
|
||||||
|
regime_prior_bear: float = 0.42
|
||||||
|
|
||||||
|
# Exit engine
|
||||||
|
trailing_stop_atr_multiplier: float = 2.0
|
||||||
|
|
||||||
|
# Polling
|
||||||
|
polling_interval_seconds: int = 30
|
||||||
|
|
||||||
|
# -- Derived sub-configs ------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hard_filter_config(self) -> HardFilterConfig:
|
||||||
|
return HardFilterConfig(
|
||||||
|
valuation_min=self.hard_filter_valuation_min,
|
||||||
|
earnings_days=self.hard_filter_earnings_days,
|
||||||
|
macro_bias_skip=self.hard_filter_macro_bias_skip,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heuristic_config(self) -> HeuristicConfig:
|
||||||
|
return HeuristicConfig(
|
||||||
|
buy_confidence=self.heuristic_buy_confidence,
|
||||||
|
buy_s_total=self.heuristic_buy_s_total,
|
||||||
|
buy_valuation_min=self.heuristic_buy_valuation_min,
|
||||||
|
watch_confidence=self.heuristic_watch_confidence,
|
||||||
|
macro_bias_threshold=0.0,
|
||||||
|
earnings_days_threshold=self.hard_filter_earnings_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def probabilistic_config(self) -> ProbabilisticConfig:
|
||||||
|
return ProbabilisticConfig(
|
||||||
|
buy_p_up=self.prob_buy_p_up,
|
||||||
|
buy_entropy_max=self.prob_buy_entropy_max,
|
||||||
|
buy_ev_r_min=self.prob_buy_ev_r_min,
|
||||||
|
buy_valuation_min=self.prob_buy_valuation_min,
|
||||||
|
watch_p_up=self.prob_watch_p_up,
|
||||||
|
watch_entropy_max=self.prob_watch_entropy_max,
|
||||||
|
entropy_skip=self.prob_entropy_skip,
|
||||||
|
regime_prior_bull=self.regime_prior_bull,
|
||||||
|
regime_prior_range=self.regime_prior_range,
|
||||||
|
regime_prior_bear=self.regime_prior_bear,
|
||||||
|
macro_bias_threshold=0.0,
|
||||||
|
earnings_days_threshold=self.hard_filter_earnings_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_config(self) -> ExitConfig:
|
||||||
|
return ExitConfig(
|
||||||
|
trailing_stop_atr_multiplier=self.trailing_stop_atr_multiplier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config loading helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# SQL to fetch all signal_engine_* keys from the active risk_configs row's
|
||||||
|
# JSONB config column. The query extracts each top-level key/value pair and
|
||||||
|
# filters to those prefixed with ``signal_engine_``.
|
||||||
|
_CONFIG_QUERY = """
|
||||||
|
SELECT key, value
|
||||||
|
FROM (
|
||||||
|
SELECT key, value
|
||||||
|
FROM risk_configs,
|
||||||
|
jsonb_each_text(config)
|
||||||
|
WHERE active = TRUE
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) sub
|
||||||
|
WHERE key LIKE 'signal_engine_%'
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Mapping from risk_configs JSON key → SignalEngineConfig field name.
|
||||||
|
# Keys in the DB are prefixed ``signal_engine_`` which is stripped to match
|
||||||
|
# the dataclass field names.
|
||||||
|
_FIELD_TYPES: dict[str, type] = {
|
||||||
|
"dual_pipeline_enabled": bool,
|
||||||
|
"heuristic_pipeline_enabled": bool,
|
||||||
|
"probabilistic_pipeline_enabled": bool,
|
||||||
|
"shadow_mode": bool,
|
||||||
|
"timeframe_weights": dict,
|
||||||
|
"hard_filter_valuation_min": float,
|
||||||
|
"hard_filter_earnings_days": int,
|
||||||
|
"hard_filter_macro_bias_skip": float,
|
||||||
|
"heuristic_buy_confidence": float,
|
||||||
|
"heuristic_buy_s_total": float,
|
||||||
|
"heuristic_buy_valuation_min": float,
|
||||||
|
"heuristic_watch_confidence": float,
|
||||||
|
"prob_buy_p_up": float,
|
||||||
|
"prob_buy_entropy_max": float,
|
||||||
|
"prob_buy_ev_r_min": float,
|
||||||
|
"prob_buy_valuation_min": float,
|
||||||
|
"prob_watch_p_up": float,
|
||||||
|
"prob_watch_entropy_max": float,
|
||||||
|
"prob_entropy_skip": float,
|
||||||
|
"regime_prior_bull": float,
|
||||||
|
"regime_prior_range": float,
|
||||||
|
"regime_prior_bear": float,
|
||||||
|
"trailing_stop_atr_multiplier": float,
|
||||||
|
"polling_interval_seconds": int,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_value(raw: str, target_type: type) -> Any:
|
||||||
|
"""Coerce a raw string value from the DB/env into *target_type*.
|
||||||
|
|
||||||
|
Booleans accept ``true``/``false`` (case-insensitive).
|
||||||
|
Dicts are parsed as JSON.
|
||||||
|
"""
|
||||||
|
if target_type is bool:
|
||||||
|
return raw.lower() in ("true", "1", "yes")
|
||||||
|
if target_type is dict:
|
||||||
|
return json.loads(raw)
|
||||||
|
if target_type is int:
|
||||||
|
return int(raw)
|
||||||
|
if target_type is float:
|
||||||
|
return float(raw)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_db_rows(
|
||||||
|
config: SignalEngineConfig,
|
||||||
|
rows: list[tuple[str, str]],
|
||||||
|
) -> None:
|
||||||
|
"""Mutate *config* in-place from ``(key, value)`` DB rows.
|
||||||
|
|
||||||
|
Keys are expected to be prefixed ``signal_engine_`` — the prefix is
|
||||||
|
stripped before matching against dataclass fields.
|
||||||
|
"""
|
||||||
|
for key, value in rows:
|
||||||
|
field_name = key.removeprefix("signal_engine_")
|
||||||
|
target_type = _FIELD_TYPES.get(field_name)
|
||||||
|
if target_type is None:
|
||||||
|
logger.debug("Ignoring unknown signal_engine config key: %s", key)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = _parse_value(value, target_type)
|
||||||
|
setattr(config, field_name, parsed)
|
||||||
|
except (ValueError, TypeError, json.JSONDecodeError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid value for signal_engine config key %s: %r — keeping default",
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_env_overrides(config: SignalEngineConfig) -> None:
|
||||||
|
"""Override config fields from environment variables.
|
||||||
|
|
||||||
|
Environment variables use the ``SIGNAL_ENGINE_`` prefix (upper-case).
|
||||||
|
For example ``SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED=true`` overrides
|
||||||
|
``dual_pipeline_enabled``.
|
||||||
|
"""
|
||||||
|
prefix = "SIGNAL_ENGINE_"
|
||||||
|
for env_key, env_value in os.environ.items():
|
||||||
|
if not env_key.startswith(prefix):
|
||||||
|
continue
|
||||||
|
field_name = env_key[len(prefix):].lower()
|
||||||
|
target_type = _FIELD_TYPES.get(field_name)
|
||||||
|
if target_type is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = _parse_value(env_value, target_type)
|
||||||
|
setattr(config, field_name, parsed)
|
||||||
|
except (ValueError, TypeError, json.JSONDecodeError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid env override %s=%r — keeping previous value",
|
||||||
|
env_key,
|
||||||
|
env_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def load_config(pool: Any) -> SignalEngineConfig:
|
||||||
|
"""Load signal engine configuration from the database and environment.
|
||||||
|
|
||||||
|
1. Start with safe defaults (``SignalEngineConfig()``).
|
||||||
|
2. Query ``risk_configs`` for keys prefixed ``signal_engine_``.
|
||||||
|
3. Apply matching values over the defaults.
|
||||||
|
4. Apply environment variable overrides (``SIGNAL_ENGINE_*``).
|
||||||
|
5. On any DB error, fall back to defaults with ``dual_pipeline_enabled=False``.
|
||||||
|
|
||||||
|
The *pool* argument is an ``asyncpg.Pool`` (typed as ``Any`` to avoid a
|
||||||
|
hard import dependency at module level).
|
||||||
|
|
||||||
|
Requirements: 13.1, 13.6, 13.7
|
||||||
|
"""
|
||||||
|
config = SignalEngineConfig()
|
||||||
|
|
||||||
|
# Step 1 — read from risk_configs
|
||||||
|
try:
|
||||||
|
rows = await pool.fetch(_CONFIG_QUERY)
|
||||||
|
if rows:
|
||||||
|
_apply_db_rows(config, [(r["key"], r["value"]) for r in rows])
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to load signal engine config from risk_configs — "
|
||||||
|
"defaulting to disabled (fail-safe)",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
# Ensure fail-safe: dual pipeline stays off
|
||||||
|
config.dual_pipeline_enabled = False
|
||||||
|
|
||||||
|
# Step 2 — environment overrides (always applied, even after DB failure)
|
||||||
|
_apply_env_overrides(config)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Signal engine config loaded: dual_pipeline_enabled=%s, "
|
||||||
|
"heuristic=%s, probabilistic=%s, shadow_mode=%s, "
|
||||||
|
"polling_interval=%ds",
|
||||||
|
config.dual_pipeline_enabled,
|
||||||
|
config.heuristic_pipeline_enabled,
|
||||||
|
config.probabilistic_pipeline_enabled,
|
||||||
|
config.shadow_mode,
|
||||||
|
config.polling_interval_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Multi-Timeframe Confluence Engine.
|
||||||
|
|
||||||
|
Evaluates signals across multiple timeframes and computes weighted confluence
|
||||||
|
scores. Signals must trigger on at least 2 timeframes **and** include at
|
||||||
|
least one higher-timeframe anchor (D, W, or M) to pass the confluence filter.
|
||||||
|
|
||||||
|
The weighted confluence score is:
|
||||||
|
|
||||||
|
C_confluence = Σ(w_tf · s_tf)
|
||||||
|
|
||||||
|
where ``w_tf`` is the timeframe weight and ``s_tf`` is the signal strength on
|
||||||
|
that timeframe (only summed over timeframes where the signal triggered).
|
||||||
|
|
||||||
|
Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
ConfluenceSignal,
|
||||||
|
SignalDirection,
|
||||||
|
SignalResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Higher-timeframe anchors — at least one must be present for a signal to pass.
|
||||||
|
HIGHER_TIMEFRAME_ANCHORS: frozenset[str] = frozenset({"D", "W", "M"})
|
||||||
|
|
||||||
|
# Minimum number of timeframes a signal must trigger on.
|
||||||
|
MIN_TIMEFRAME_COUNT: int = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_direction(results: dict[str, SignalResult]) -> SignalDirection:
|
||||||
|
"""Determine the dominant direction from a set of per-timeframe results.
|
||||||
|
|
||||||
|
Counts bullish vs bearish votes across active timeframes. Ties resolve
|
||||||
|
to NEUTRAL.
|
||||||
|
"""
|
||||||
|
counts: Counter[SignalDirection] = Counter()
|
||||||
|
for sr in results.values():
|
||||||
|
counts[sr.direction] += 1
|
||||||
|
|
||||||
|
bullish = counts.get(SignalDirection.BULLISH, 0)
|
||||||
|
bearish = counts.get(SignalDirection.BEARISH, 0)
|
||||||
|
|
||||||
|
if bullish > bearish:
|
||||||
|
return SignalDirection.BULLISH
|
||||||
|
if bearish > bullish:
|
||||||
|
return SignalDirection.BEARISH
|
||||||
|
return SignalDirection.NEUTRAL
|
||||||
|
|
||||||
|
|
||||||
|
def compute_confluence(
|
||||||
|
signal_results: dict[str, dict[str, SignalResult]],
|
||||||
|
weights: dict[str, float],
|
||||||
|
) -> list[ConfluenceSignal]:
|
||||||
|
"""Compute weighted confluence scores across timeframes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal_results: ``{signal_type: {timeframe: SignalResult}}``.
|
||||||
|
Each inner dict maps timeframe labels (e.g. ``"D"``, ``"H4"``)
|
||||||
|
to the :class:`SignalResult` produced by the signal evaluator on
|
||||||
|
that timeframe.
|
||||||
|
weights: ``{timeframe: weight}`` e.g.
|
||||||
|
``{"M30": 0.03, "H1": 0.07, "H4": 0.15, "D": 0.30, "W": 0.30, "M": 0.15}``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of :class:`ConfluenceSignal` objects that pass **both** filters:
|
||||||
|
|
||||||
|
1. **Minimum confluence threshold** — the signal must trigger on at
|
||||||
|
least :data:`MIN_TIMEFRAME_COUNT` (2) timeframes.
|
||||||
|
2. **Higher-timeframe anchor** — at least one of D, W, or M must be
|
||||||
|
among the active timeframes.
|
||||||
|
|
||||||
|
Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6
|
||||||
|
"""
|
||||||
|
confluence_signals: list[ConfluenceSignal] = []
|
||||||
|
|
||||||
|
for signal_type, tf_results in signal_results.items():
|
||||||
|
active_timeframes = list(tf_results.keys())
|
||||||
|
|
||||||
|
# 3.3 — Minimum confluence threshold: discard if < 2 timeframes
|
||||||
|
if len(active_timeframes) < MIN_TIMEFRAME_COUNT:
|
||||||
|
logger.debug(
|
||||||
|
"Signal %s discarded: only %d timeframe(s) triggered (need >= %d)",
|
||||||
|
signal_type,
|
||||||
|
len(active_timeframes),
|
||||||
|
MIN_TIMEFRAME_COUNT,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3.4 — Higher-timeframe anchor: discard if none of D, W, M present
|
||||||
|
if not HIGHER_TIMEFRAME_ANCHORS.intersection(active_timeframes):
|
||||||
|
logger.debug(
|
||||||
|
"Signal %s discarded: no higher-timeframe anchor (D/W/M) "
|
||||||
|
"among active timeframes %s",
|
||||||
|
signal_type,
|
||||||
|
active_timeframes,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3.2 — Compute weighted confluence score
|
||||||
|
per_timeframe: dict[str, float] = {}
|
||||||
|
confluence_score = 0.0
|
||||||
|
for tf, sr in tf_results.items():
|
||||||
|
w = weights.get(tf, 0.0)
|
||||||
|
per_timeframe[tf] = sr.strength
|
||||||
|
confluence_score += w * sr.strength
|
||||||
|
|
||||||
|
# Determine dominant direction across active timeframes
|
||||||
|
direction = _dominant_direction(tf_results)
|
||||||
|
|
||||||
|
confluence_signals.append(
|
||||||
|
ConfluenceSignal(
|
||||||
|
signal_type=signal_type,
|
||||||
|
direction=direction,
|
||||||
|
confluence_score=confluence_score,
|
||||||
|
active_timeframes=active_timeframes,
|
||||||
|
per_timeframe=per_timeframe,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Signal %s passed confluence: score=%.4f, direction=%s, "
|
||||||
|
"timeframes=%s",
|
||||||
|
signal_type,
|
||||||
|
confluence_score,
|
||||||
|
direction.value,
|
||||||
|
active_timeframes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return confluence_signals
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Signal cluster classification and within-cluster correlation penalty.
|
||||||
|
|
||||||
|
Groups signals into four clusters — momentum, structure, volatility,
|
||||||
|
fundamentals — and applies exponential decay within each cluster to prevent
|
||||||
|
likelihood ratio stacking inflation in the Bayesian pipeline.
|
||||||
|
|
||||||
|
Within a cluster the strongest signal (by ``|log_lr|``) contributes at full
|
||||||
|
weight; subsequent signals contribute at ``0.5^(n-1)`` decay. Signals in
|
||||||
|
different clusters are treated as independent (no penalty). Single-signal
|
||||||
|
clusters receive no penalty.
|
||||||
|
|
||||||
|
Requirements: 7.1, 7.2, 7.3, 7.4
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from services.signal_engine.models import LikelihoodRatio
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal cluster enum
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SignalCluster(str, Enum):
|
||||||
|
"""Correlation cluster for grouping related signals."""
|
||||||
|
|
||||||
|
MOMENTUM = "momentum" # MA stack, RSI
|
||||||
|
STRUCTURE = "structure" # Fibonacci, Elliott Wave, Cup & Handle
|
||||||
|
VOLATILITY = "volatility" # ATR-based, Bollinger-derived
|
||||||
|
FUNDAMENTALS = "fundamentals" # valuation, earnings, macro
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal type → cluster mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SIGNAL_CLUSTER_MAP: dict[str, SignalCluster] = {
|
||||||
|
# Momentum
|
||||||
|
"ma_stack": SignalCluster.MOMENTUM,
|
||||||
|
"rsi": SignalCluster.MOMENTUM,
|
||||||
|
# Structure
|
||||||
|
"fibonacci": SignalCluster.STRUCTURE,
|
||||||
|
"elliott_wave": SignalCluster.STRUCTURE,
|
||||||
|
"cup_handle": SignalCluster.STRUCTURE,
|
||||||
|
# Volatility
|
||||||
|
"atr": SignalCluster.VOLATILITY,
|
||||||
|
"bollinger": SignalCluster.VOLATILITY,
|
||||||
|
# Fundamentals
|
||||||
|
"valuation": SignalCluster.FUNDAMENTALS,
|
||||||
|
"earnings": SignalCluster.FUNDAMENTALS,
|
||||||
|
"macro": SignalCluster.FUNDAMENTALS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decay factor applied to successive signals within the same cluster.
|
||||||
|
_WITHIN_CLUSTER_DECAY = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def classify_signal(signal_type: str) -> SignalCluster:
|
||||||
|
"""Map a signal type string to its correlation cluster.
|
||||||
|
|
||||||
|
Falls back to :pyattr:`SignalCluster.FUNDAMENTALS` for unknown signal
|
||||||
|
types so that unrecognised signals still participate in the penalty
|
||||||
|
system rather than silently bypassing it.
|
||||||
|
"""
|
||||||
|
cluster = _SIGNAL_CLUSTER_MAP.get(signal_type)
|
||||||
|
if cluster is None:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown signal type %r — defaulting to FUNDAMENTALS cluster",
|
||||||
|
signal_type,
|
||||||
|
)
|
||||||
|
return SignalCluster.FUNDAMENTALS
|
||||||
|
return cluster
|
||||||
|
|
||||||
|
|
||||||
|
def apply_correlation_penalty(
|
||||||
|
likelihood_ratios: list[LikelihoodRatio],
|
||||||
|
) -> list[LikelihoodRatio]:
|
||||||
|
"""Apply within-cluster decay penalty to correlated signals.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
1. Group LRs by cluster.
|
||||||
|
2. Within each cluster, sort by ``abs(log_lr)`` descending (strongest
|
||||||
|
first).
|
||||||
|
3. The strongest signal keeps its full ``log_lr`` as
|
||||||
|
``penalized_log_lr``.
|
||||||
|
4. The *n*-th signal (1-indexed) receives
|
||||||
|
``penalized_log_lr = log_lr * 0.5^(n-1)``.
|
||||||
|
5. Single-signal clusters are untouched (``penalized_log_lr = log_lr``).
|
||||||
|
6. Cross-cluster signals are independent — no penalty applied across
|
||||||
|
clusters.
|
||||||
|
|
||||||
|
Returns a **new** list of :class:`LikelihoodRatio` instances with
|
||||||
|
updated ``penalized_log_lr`` values. The original objects are not
|
||||||
|
mutated.
|
||||||
|
"""
|
||||||
|
if not likelihood_ratios:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Group by cluster
|
||||||
|
clusters: dict[str, list[tuple[int, LikelihoodRatio]]] = defaultdict(list)
|
||||||
|
for idx, lr in enumerate(likelihood_ratios):
|
||||||
|
clusters[lr.cluster].append((idx, lr))
|
||||||
|
|
||||||
|
# Build result list preserving original order
|
||||||
|
result: list[LikelihoodRatio | None] = [None] * len(likelihood_ratios)
|
||||||
|
|
||||||
|
for cluster_name, members in clusters.items():
|
||||||
|
# Sort by abs(log_lr) descending — strongest first
|
||||||
|
sorted_members = sorted(members, key=lambda t: abs(t[1].log_lr), reverse=True)
|
||||||
|
|
||||||
|
for rank, (orig_idx, lr) in enumerate(sorted_members):
|
||||||
|
decay = _WITHIN_CLUSTER_DECAY ** rank # 0.5^0=1, 0.5^1=0.5, ...
|
||||||
|
penalized = lr.log_lr * decay
|
||||||
|
|
||||||
|
result[orig_idx] = LikelihoodRatio(
|
||||||
|
signal_type=lr.signal_type,
|
||||||
|
cluster=lr.cluster,
|
||||||
|
lr=lr.lr,
|
||||||
|
log_lr=lr.log_lr,
|
||||||
|
penalized_log_lr=penalized,
|
||||||
|
hit_rate=lr.hit_rate,
|
||||||
|
strength=lr.strength,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Safety: should never happen, but guard against it
|
||||||
|
return [r for r in result if r is not None]
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Delta Analyzer — compares heuristic and probabilistic pipeline verdicts.
|
||||||
|
|
||||||
|
Computes agreement flags, confidence deltas, disagreement reasons, and
|
||||||
|
tracks a rolling 100-evaluation agreement rate per ticker in Redis.
|
||||||
|
|
||||||
|
Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import redis.asyncio
|
||||||
|
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
DeltaResult,
|
||||||
|
HeuristicResult,
|
||||||
|
ProbabilisticResult,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Redis key pattern for rolling agreement tracking
|
||||||
|
_AGREEMENT_KEY_PREFIX = "stonks:signal_engine:agreement"
|
||||||
|
|
||||||
|
# Maximum number of evaluations to track for rolling agreement rate
|
||||||
|
_ROLLING_WINDOW = 100
|
||||||
|
|
||||||
|
# Agreement rate threshold below which a warning is logged
|
||||||
|
_AGREEMENT_WARNING_THRESHOLD = 0.50
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_disagreement_reasons(
|
||||||
|
heuristic: HeuristicResult,
|
||||||
|
probabilistic: ProbabilisticResult,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Identify reasons for pipeline disagreement.
|
||||||
|
|
||||||
|
Compares which conditions each pipeline met or failed to produce
|
||||||
|
human-readable disagreement reasons for training signal generation.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if heuristic.verdict == probabilistic.verdict:
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
# Heuristic-side reasons
|
||||||
|
if heuristic.confidence < 0.70:
|
||||||
|
reasons.append("heuristic_confidence_below_threshold")
|
||||||
|
if heuristic.s_total < 1.2:
|
||||||
|
reasons.append("heuristic_s_total_below_threshold")
|
||||||
|
|
||||||
|
# Probabilistic-side reasons
|
||||||
|
if probabilistic.p_up < 0.60:
|
||||||
|
reasons.append("probabilistic_p_up_below_threshold")
|
||||||
|
if probabilistic.entropy > 0.90:
|
||||||
|
reasons.append("probabilistic_entropy_too_high")
|
||||||
|
if probabilistic.ev_r < 1.5:
|
||||||
|
reasons.append("EV_R_below_threshold")
|
||||||
|
|
||||||
|
# Verdict-specific context
|
||||||
|
if heuristic.verdict == Verdict.BUY and probabilistic.verdict != Verdict.BUY:
|
||||||
|
reasons.append("heuristic_buy_probabilistic_disagrees")
|
||||||
|
elif probabilistic.verdict == Verdict.BUY and heuristic.verdict != Verdict.BUY:
|
||||||
|
reasons.append("probabilistic_buy_heuristic_disagrees")
|
||||||
|
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_delta(
|
||||||
|
heuristic: HeuristicResult,
|
||||||
|
probabilistic: ProbabilisticResult,
|
||||||
|
redis_client: redis.asyncio.Redis,
|
||||||
|
ticker: str,
|
||||||
|
) -> DeltaResult:
|
||||||
|
"""Compare pipeline verdicts and track agreement metrics.
|
||||||
|
|
||||||
|
1. Compute agreement flag (both verdicts identical).
|
||||||
|
2. Compute confidence delta: ``|heuristic_confidence - probabilistic_P_up|``.
|
||||||
|
3. Record disagreement reasons when verdicts differ.
|
||||||
|
4. Track rolling 100-evaluation agreement rate in Redis.
|
||||||
|
5. Log warning when agreement rate drops below 0.50.
|
||||||
|
|
||||||
|
Returns a ``DeltaResult`` with all computed fields.
|
||||||
|
"""
|
||||||
|
# Step 1: Agreement flag
|
||||||
|
agreement = heuristic.verdict == probabilistic.verdict
|
||||||
|
|
||||||
|
# Step 2: Confidence delta
|
||||||
|
confidence_delta = abs(heuristic.confidence - probabilistic.p_up)
|
||||||
|
|
||||||
|
# Step 3: Disagreement reasons
|
||||||
|
disagreement_reasons = _compute_disagreement_reasons(heuristic, probabilistic)
|
||||||
|
|
||||||
|
# Step 4: Rolling agreement rate in Redis
|
||||||
|
rolling_agreement_rate: float | None = None
|
||||||
|
agreement_key = f"{_AGREEMENT_KEY_PREFIX}:{ticker}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Push the agreement result (1 for agree, 0 for disagree)
|
||||||
|
await redis_client.lpush(agreement_key, "1" if agreement else "0")
|
||||||
|
# Trim to the last _ROLLING_WINDOW evaluations
|
||||||
|
await redis_client.ltrim(agreement_key, 0, _ROLLING_WINDOW - 1)
|
||||||
|
# Compute the rolling agreement rate
|
||||||
|
values = await redis_client.lrange(agreement_key, 0, _ROLLING_WINDOW - 1)
|
||||||
|
if values:
|
||||||
|
agree_count = sum(1 for v in values if v == b"1" or v == "1")
|
||||||
|
rolling_agreement_rate = agree_count / len(values)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update rolling agreement rate in Redis for %s",
|
||||||
|
ticker,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Log warning when agreement rate drops below threshold
|
||||||
|
if (
|
||||||
|
rolling_agreement_rate is not None
|
||||||
|
and rolling_agreement_rate < _AGREEMENT_WARNING_THRESHOLD
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Persistent pipeline disagreement for %s: rolling agreement rate %.2f "
|
||||||
|
"(below %.2f threshold over last %d evaluations)",
|
||||||
|
ticker,
|
||||||
|
rolling_agreement_rate,
|
||||||
|
_AGREEMENT_WARNING_THRESHOLD,
|
||||||
|
_ROLLING_WINDOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 6: Return DeltaResult
|
||||||
|
return DeltaResult(
|
||||||
|
agreement=agreement,
|
||||||
|
confidence_delta=round(confidence_delta, 6),
|
||||||
|
heuristic_verdict=heuristic.verdict.value,
|
||||||
|
probabilistic_verdict=probabilistic.verdict.value,
|
||||||
|
disagreement_reasons=disagreement_reasons,
|
||||||
|
rolling_agreement_rate=rolling_agreement_rate,
|
||||||
|
)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Exit engine — position-level exit management.
|
||||||
|
|
||||||
|
Evaluates stop-loss hits, take-profit targets, and trailing ATR-based stops
|
||||||
|
for open positions. Called once per evaluation tick *before* the signal
|
||||||
|
pipelines run so that exit signals take priority over new entry signals.
|
||||||
|
|
||||||
|
Priority order (first match wins per position):
|
||||||
|
1. stop_loss hit → EXIT_FULL, reason ``"stop_hit"``
|
||||||
|
2. target_2 hit → EXIT_FULL, reason ``"target_2_hit"``
|
||||||
|
3. trailing stop → EXIT_FULL, reason ``"trailing_stop_hit"``
|
||||||
|
4. target_1 hit → EXIT_HALF, reason ``"target_1_hit"``
|
||||||
|
|
||||||
|
Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from services.signal_engine.config import ExitConfig
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
ExitSignal,
|
||||||
|
ExitType,
|
||||||
|
OpenPositionState,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_exits(
|
||||||
|
positions: list[OpenPositionState],
|
||||||
|
current_prices: dict[str, float],
|
||||||
|
config: ExitConfig,
|
||||||
|
) -> list[ExitSignal]:
|
||||||
|
"""Evaluate exit conditions for all open positions.
|
||||||
|
|
||||||
|
For each position the current price is looked up in *current_prices*
|
||||||
|
(keyed by ticker). If the ticker is absent the position's own
|
||||||
|
``current_price`` field is used as a fallback.
|
||||||
|
|
||||||
|
Checks are applied in priority order — only the **first** matching
|
||||||
|
condition per position emits an ``ExitSignal``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
positions:
|
||||||
|
Snapshots of open positions to evaluate.
|
||||||
|
current_prices:
|
||||||
|
Latest prices keyed by ticker symbol.
|
||||||
|
config:
|
||||||
|
Exit engine configuration (trailing stop ATR multiplier, etc.).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[ExitSignal]
|
||||||
|
One signal per position that triggered an exit condition.
|
||||||
|
Positions with no exit condition produce no signal.
|
||||||
|
"""
|
||||||
|
signals: list[ExitSignal] = []
|
||||||
|
|
||||||
|
for pos in positions:
|
||||||
|
price = current_prices.get(pos.ticker, pos.current_price)
|
||||||
|
|
||||||
|
signal = _evaluate_single_position(pos, price, config)
|
||||||
|
if signal is not None:
|
||||||
|
signals.append(signal)
|
||||||
|
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_single_position(
|
||||||
|
pos: OpenPositionState,
|
||||||
|
price: float,
|
||||||
|
config: ExitConfig,
|
||||||
|
) -> ExitSignal | None:
|
||||||
|
"""Check exit conditions for a single position in priority order.
|
||||||
|
|
||||||
|
Priority: stop_loss > target_2 > trailing_stop > target_1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 1. Stop-loss hit (highest priority)
|
||||||
|
if price <= pos.stop_loss:
|
||||||
|
return ExitSignal(
|
||||||
|
position_id=pos.position_id,
|
||||||
|
ticker=pos.ticker,
|
||||||
|
exit_type=ExitType.EXIT_FULL,
|
||||||
|
reason="stop_hit",
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Target 2 hit → full exit
|
||||||
|
if price >= pos.target_2:
|
||||||
|
return ExitSignal(
|
||||||
|
position_id=pos.position_id,
|
||||||
|
ticker=pos.ticker,
|
||||||
|
exit_type=ExitType.EXIT_FULL,
|
||||||
|
reason="target_2_hit",
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Trailing stop (only active after partial exit)
|
||||||
|
if pos.partial_exit_done:
|
||||||
|
trailing_stop = _compute_trailing_stop(pos, price, config)
|
||||||
|
if price <= trailing_stop:
|
||||||
|
return ExitSignal(
|
||||||
|
position_id=pos.position_id,
|
||||||
|
ticker=pos.ticker,
|
||||||
|
exit_type=ExitType.EXIT_FULL,
|
||||||
|
reason="trailing_stop_hit",
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Target 1 hit → partial exit (only if not already done)
|
||||||
|
if not pos.partial_exit_done and price >= pos.target_1:
|
||||||
|
return ExitSignal(
|
||||||
|
position_id=pos.position_id,
|
||||||
|
ticker=pos.ticker,
|
||||||
|
exit_type=ExitType.EXIT_HALF,
|
||||||
|
reason="target_1_hit",
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_trailing_stop(
|
||||||
|
pos: OpenPositionState,
|
||||||
|
price: float,
|
||||||
|
config: ExitConfig,
|
||||||
|
) -> float:
|
||||||
|
"""Compute the effective trailing stop level.
|
||||||
|
|
||||||
|
The trailing stop is ``price - ATR * multiplier``, but it only
|
||||||
|
ratchets **upward** — if the position already has a higher trailing
|
||||||
|
stop recorded, that value is kept.
|
||||||
|
|
||||||
|
When ATR is unavailable (``None``), the existing ``trailing_stop``
|
||||||
|
on the position is returned as-is. If neither is set, returns 0.0
|
||||||
|
(effectively no trailing stop).
|
||||||
|
"""
|
||||||
|
existing = pos.trailing_stop if pos.trailing_stop is not None else 0.0
|
||||||
|
|
||||||
|
if pos.atr is None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
new_level = price - pos.atr * config.trailing_stop_atr_multiplier
|
||||||
|
|
||||||
|
# Ratchet upward only
|
||||||
|
return max(existing, new_level)
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Output Formatter — assembles the structured SignalOutput contract.
|
||||||
|
|
||||||
|
Populates trade plans based on verdict combinations and maps
|
||||||
|
``SignalOutput`` to the existing ``Recommendation`` schema for
|
||||||
|
trading engine compatibility.
|
||||||
|
|
||||||
|
Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 12.1, 12.2, 12.3, 12.4, 12.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.shared.schemas import (
|
||||||
|
ActionType,
|
||||||
|
PositionSizing,
|
||||||
|
Recommendation,
|
||||||
|
RecommendationMode,
|
||||||
|
)
|
||||||
|
from services.signal_engine.config import SignalEngineConfig
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
DeltaResult,
|
||||||
|
ExitSignal,
|
||||||
|
HeuristicResult,
|
||||||
|
ProbabilisticResult,
|
||||||
|
SignalOutput,
|
||||||
|
TradePlan,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Position sizing constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Full position sizing (heuristic-only or dual confirmed)
|
||||||
|
_FULL_POSITION_SIZE_PCT = 0.02
|
||||||
|
_FULL_MAX_LOSS_PCT = 0.005
|
||||||
|
|
||||||
|
# Reduced position sizing for probabilistic-only BUY (50% of standard)
|
||||||
|
_REDUCED_POSITION_SIZE_PCT = 0.01
|
||||||
|
|
||||||
|
# Trade plan price levels (relative to entry)
|
||||||
|
_STOP_LOSS_FACTOR = 0.95
|
||||||
|
_TARGET_1_FACTOR = 1.05
|
||||||
|
_TARGET_2_FACTOR = 1.10
|
||||||
|
|
||||||
|
|
||||||
|
def _build_trade_plan(
|
||||||
|
price: float,
|
||||||
|
*,
|
||||||
|
dual_confirmed: bool = False,
|
||||||
|
probabilistic_only: bool = False,
|
||||||
|
) -> TradePlan:
|
||||||
|
"""Build a trade plan with position sizing based on confirmation mode.
|
||||||
|
|
||||||
|
- dual_confirmed: full position sizing with dual_confirmed flag
|
||||||
|
- probabilistic_only: 50% position sizing with probabilistic_only flag
|
||||||
|
- heuristic-only (neither flag): standard full position sizing
|
||||||
|
"""
|
||||||
|
if dual_confirmed:
|
||||||
|
position_size_pct = _FULL_POSITION_SIZE_PCT
|
||||||
|
max_loss_pct = _FULL_MAX_LOSS_PCT
|
||||||
|
elif probabilistic_only:
|
||||||
|
position_size_pct = _REDUCED_POSITION_SIZE_PCT
|
||||||
|
max_loss_pct = _FULL_MAX_LOSS_PCT
|
||||||
|
else:
|
||||||
|
# Heuristic-only BUY
|
||||||
|
position_size_pct = _FULL_POSITION_SIZE_PCT
|
||||||
|
max_loss_pct = _FULL_MAX_LOSS_PCT
|
||||||
|
|
||||||
|
return TradePlan(
|
||||||
|
entry_price=price,
|
||||||
|
stop_loss=round(price * _STOP_LOSS_FACTOR, 6),
|
||||||
|
target_1=round(price * _TARGET_1_FACTOR, 6),
|
||||||
|
target_2=round(price * _TARGET_2_FACTOR, 6),
|
||||||
|
position_size_pct=position_size_pct,
|
||||||
|
max_loss_pct=max_loss_pct,
|
||||||
|
dual_confirmed=dual_confirmed,
|
||||||
|
probabilistic_only=probabilistic_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_output(
|
||||||
|
ticker: str,
|
||||||
|
price: float,
|
||||||
|
heuristic: HeuristicResult,
|
||||||
|
probabilistic: ProbabilisticResult,
|
||||||
|
delta: DeltaResult,
|
||||||
|
exit_signals: list[ExitSignal],
|
||||||
|
config: SignalEngineConfig,
|
||||||
|
) -> SignalOutput:
|
||||||
|
"""Assemble the structured ``SignalOutput`` contract.
|
||||||
|
|
||||||
|
Trade plan logic:
|
||||||
|
- Both BUY → ``dual_confirmed``, full position sizing
|
||||||
|
- Probabilistic-only BUY → ``probabilistic_only``, 50% position sizing
|
||||||
|
- Heuristic-only BUY → standard position sizing
|
||||||
|
- No BUY → no trade_plan (WATCH/SKIP persisted for analysis)
|
||||||
|
"""
|
||||||
|
heuristic_buy = heuristic.verdict == Verdict.BUY
|
||||||
|
probabilistic_buy = probabilistic.verdict == Verdict.BUY
|
||||||
|
|
||||||
|
trade_plan: TradePlan | None = None
|
||||||
|
|
||||||
|
if heuristic_buy and probabilistic_buy:
|
||||||
|
# Both pipelines agree on BUY → dual confirmed
|
||||||
|
trade_plan = _build_trade_plan(
|
||||||
|
price, dual_confirmed=True, probabilistic_only=False
|
||||||
|
)
|
||||||
|
elif probabilistic_buy and not heuristic_buy:
|
||||||
|
# Probabilistic-only BUY → reduced position sizing
|
||||||
|
trade_plan = _build_trade_plan(
|
||||||
|
price, dual_confirmed=False, probabilistic_only=True
|
||||||
|
)
|
||||||
|
elif heuristic_buy and not probabilistic_buy:
|
||||||
|
# Heuristic-only BUY → standard position sizing
|
||||||
|
trade_plan = _build_trade_plan(
|
||||||
|
price, dual_confirmed=False, probabilistic_only=False
|
||||||
|
)
|
||||||
|
# else: No BUY → no trade_plan
|
||||||
|
|
||||||
|
return SignalOutput(
|
||||||
|
ticker=ticker,
|
||||||
|
timestamp=datetime.now(tz=timezone.utc),
|
||||||
|
price=price,
|
||||||
|
# Heuristic pipeline section
|
||||||
|
heuristic_verdict=heuristic.verdict.value,
|
||||||
|
heuristic_confidence=heuristic.confidence,
|
||||||
|
heuristic_s_total=heuristic.s_total,
|
||||||
|
# Probabilistic pipeline section
|
||||||
|
probabilistic_verdict=probabilistic.verdict.value,
|
||||||
|
probabilistic_p_up=probabilistic.p_up,
|
||||||
|
probabilistic_entropy=probabilistic.entropy,
|
||||||
|
probabilistic_ev_r=probabilistic.ev_r,
|
||||||
|
# Delta analysis section
|
||||||
|
delta_agreement=delta.agreement,
|
||||||
|
delta_confidence_delta=delta.confidence_delta,
|
||||||
|
delta_reasons=delta.disagreement_reasons,
|
||||||
|
# Trade plan and exit signals
|
||||||
|
trade_plan=trade_plan,
|
||||||
|
exit_signals=exit_signals,
|
||||||
|
# Detail payloads for audit
|
||||||
|
heuristic_detail=heuristic.model_dump(),
|
||||||
|
probabilistic_detail=probabilistic.model_dump(),
|
||||||
|
# Pipeline mode metadata
|
||||||
|
pipeline_mode="dual_pipeline",
|
||||||
|
shadow_mode=config.shadow_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def signal_output_to_recommendation(output: SignalOutput) -> Recommendation:
|
||||||
|
"""Map a ``SignalOutput`` to the existing ``Recommendation`` schema.
|
||||||
|
|
||||||
|
Enables the trading engine to consume dual-pipeline outputs without
|
||||||
|
modification to its core ``evaluate_recommendation`` logic.
|
||||||
|
|
||||||
|
Confidence mapping:
|
||||||
|
- Dual confirmed: ``max(heuristic_confidence, probabilistic_P_up)``
|
||||||
|
- Probabilistic only: ``probabilistic_P_up * 0.8`` (20% haircut)
|
||||||
|
- Heuristic only: ``heuristic_confidence``
|
||||||
|
- No BUY: ``max(heuristic_confidence, probabilistic_P_up)``
|
||||||
|
|
||||||
|
Action mapping:
|
||||||
|
- BUY (either pipeline) → ``ActionType.BUY``
|
||||||
|
- WATCH → ``ActionType.WATCH``
|
||||||
|
- SKIP → ``ActionType.HOLD``
|
||||||
|
|
||||||
|
Mode: always ``RecommendationMode.PAPER_ELIGIBLE``
|
||||||
|
"""
|
||||||
|
trade_plan = output.trade_plan
|
||||||
|
|
||||||
|
# Determine confidence based on confirmation mode
|
||||||
|
if trade_plan is not None and trade_plan.dual_confirmed:
|
||||||
|
confidence = max(output.heuristic_confidence, output.probabilistic_p_up)
|
||||||
|
elif trade_plan is not None and trade_plan.probabilistic_only:
|
||||||
|
confidence = output.probabilistic_p_up * 0.8
|
||||||
|
elif trade_plan is not None:
|
||||||
|
# Heuristic-only BUY
|
||||||
|
confidence = output.heuristic_confidence
|
||||||
|
else:
|
||||||
|
# No trade plan — use the best available confidence
|
||||||
|
confidence = max(output.heuristic_confidence, output.probabilistic_p_up)
|
||||||
|
|
||||||
|
# Clamp confidence to [0, 1]
|
||||||
|
confidence = max(0.0, min(1.0, confidence))
|
||||||
|
|
||||||
|
# Determine action from verdicts
|
||||||
|
h_verdict = output.heuristic_verdict
|
||||||
|
p_verdict = output.probabilistic_verdict
|
||||||
|
|
||||||
|
if h_verdict == Verdict.BUY.value or p_verdict == Verdict.BUY.value:
|
||||||
|
action = ActionType.BUY
|
||||||
|
elif h_verdict == Verdict.WATCH.value or p_verdict == Verdict.WATCH.value:
|
||||||
|
action = ActionType.WATCH
|
||||||
|
else:
|
||||||
|
action = ActionType.HOLD
|
||||||
|
|
||||||
|
# Build position sizing from trade plan if available
|
||||||
|
position_sizing = PositionSizing()
|
||||||
|
if trade_plan is not None:
|
||||||
|
position_sizing = PositionSizing(
|
||||||
|
portfolio_pct=trade_plan.position_size_pct,
|
||||||
|
max_loss_pct=trade_plan.max_loss_pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build thesis from delta analysis
|
||||||
|
thesis_parts: list[str] = []
|
||||||
|
if trade_plan is not None and trade_plan.dual_confirmed:
|
||||||
|
thesis_parts.append("Dual-pipeline confirmed BUY signal")
|
||||||
|
elif trade_plan is not None and trade_plan.probabilistic_only:
|
||||||
|
thesis_parts.append("Probabilistic-only BUY signal (reduced sizing)")
|
||||||
|
elif trade_plan is not None:
|
||||||
|
thesis_parts.append("Heuristic-only BUY signal")
|
||||||
|
else:
|
||||||
|
thesis_parts.append(f"No BUY signal (H={h_verdict}, P={p_verdict})")
|
||||||
|
|
||||||
|
if output.delta_reasons:
|
||||||
|
thesis_parts.append(f"Delta reasons: {', '.join(output.delta_reasons)}")
|
||||||
|
|
||||||
|
return Recommendation(
|
||||||
|
recommendation_id=output.output_id,
|
||||||
|
ticker=output.ticker,
|
||||||
|
action=action,
|
||||||
|
mode=RecommendationMode.PAPER_ELIGIBLE,
|
||||||
|
confidence=confidence,
|
||||||
|
time_horizon="signal_engine",
|
||||||
|
thesis="; ".join(thesis_parts),
|
||||||
|
position_sizing=position_sizing,
|
||||||
|
pipeline_mode="dual_pipeline",
|
||||||
|
p_bull=output.probabilistic_p_up,
|
||||||
|
expected_value=output.probabilistic_ev_r,
|
||||||
|
generated_at=output.timestamp,
|
||||||
|
)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Hard Filter Engine — pre-pipeline gating for the dual-pipeline signal engine.
|
||||||
|
|
||||||
|
Evaluates macro bias, valuation score, and earnings proximity to short-circuit
|
||||||
|
both pipelines before evaluation. All conditions are checked and all triggered
|
||||||
|
reasons are collected (no short-circuit on first match).
|
||||||
|
|
||||||
|
Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from services.signal_engine.config import HardFilterConfig
|
||||||
|
from services.signal_engine.models import NormalizedInput
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HardFilterResult:
|
||||||
|
"""Outcome of the hard filter evaluation.
|
||||||
|
|
||||||
|
``filtered=True`` means the ticker should be **skipped** — both pipelines
|
||||||
|
are short-circuited. ``reasons`` lists every filter that triggered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filtered: bool = False
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_hard_filters(
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
config: HardFilterConfig,
|
||||||
|
) -> HardFilterResult:
|
||||||
|
"""Evaluate pre-pipeline hard filters.
|
||||||
|
|
||||||
|
Checks (all evaluated, not short-circuited):
|
||||||
|
- ``macro_bias == config.macro_bias_skip`` → reason ``"macro_bias_negative"``
|
||||||
|
- ``valuation_score < config.valuation_min`` → reason ``"valuation_below_threshold"``
|
||||||
|
- ``earnings_proximity_days <= config.earnings_days`` → reason ``"earnings_block"``
|
||||||
|
|
||||||
|
Missing optional fields (``valuation_score is None``,
|
||||||
|
``earnings_proximity_days is None``) do **not** trigger a filter — missing
|
||||||
|
data should not produce a false-positive SKIP.
|
||||||
|
|
||||||
|
Returns a :class:`HardFilterResult` with ``filtered=True`` when at least
|
||||||
|
one reason was recorded.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
# 4.1 — macro_bias exact equality with configured skip value
|
||||||
|
if normalized.macro_bias == config.macro_bias_skip:
|
||||||
|
reasons.append("macro_bias_negative")
|
||||||
|
|
||||||
|
# 4.2 — valuation score below minimum threshold
|
||||||
|
if (
|
||||||
|
normalized.valuation_score is not None
|
||||||
|
and normalized.valuation_score < config.valuation_min
|
||||||
|
):
|
||||||
|
reasons.append("valuation_below_threshold")
|
||||||
|
|
||||||
|
# 4.3 — earnings proximity within block window
|
||||||
|
if (
|
||||||
|
normalized.earnings_proximity_days is not None
|
||||||
|
and normalized.earnings_proximity_days <= config.earnings_days
|
||||||
|
):
|
||||||
|
reasons.append("earnings_block")
|
||||||
|
|
||||||
|
filtered = len(reasons) > 0
|
||||||
|
|
||||||
|
if filtered:
|
||||||
|
logger.info(
|
||||||
|
"Hard filter triggered for %s: %s",
|
||||||
|
normalized.ticker,
|
||||||
|
", ".join(reasons),
|
||||||
|
)
|
||||||
|
|
||||||
|
return HardFilterResult(filtered=filtered, reasons=reasons)
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Heuristic Pipeline (Pipeline A) — Deterministic scoring and verdict.
|
||||||
|
|
||||||
|
Computes ``S_total = S_company + S_macro + S_competitive`` from confluence-
|
||||||
|
filtered signals and produces a confidence-gated BUY / WATCH / SKIP verdict.
|
||||||
|
|
||||||
|
The pipeline reuses the existing ``compute_signal_weight`` infrastructure
|
||||||
|
from ``services.aggregation.scoring`` for signal weighting and follows the
|
||||||
|
three-layer signal aggregation model (company, macro, competitive).
|
||||||
|
|
||||||
|
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from services.signal_engine.config import HeuristicConfig
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
ConfluenceSignal,
|
||||||
|
HeuristicResult,
|
||||||
|
NormalizedInput,
|
||||||
|
SignalDirection,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal classification — which confluence signals belong to which layer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Company-level technical signals (Layer 1)
|
||||||
|
COMPANY_SIGNAL_TYPES: frozenset[str] = frozenset({
|
||||||
|
"fibonacci",
|
||||||
|
"ma_stack",
|
||||||
|
"rsi",
|
||||||
|
"cup_handle",
|
||||||
|
"elliott_wave",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Competitive signals (Layer 3) — future expansion
|
||||||
|
COMPETITIVE_SIGNAL_TYPES: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
# Macro weight applied to macro_bias to produce S_macro
|
||||||
|
_MACRO_WEIGHT: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Score computation helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_s_company(confluence_signals: list[ConfluenceSignal]) -> tuple[float, list[dict]]:
|
||||||
|
"""Sum confluence scores for company-level signals.
|
||||||
|
|
||||||
|
Returns the total S_company score and a list of per-signal weight
|
||||||
|
breakdowns for audit.
|
||||||
|
"""
|
||||||
|
s_company = 0.0
|
||||||
|
weights: list[dict] = []
|
||||||
|
|
||||||
|
for sig in confluence_signals:
|
||||||
|
if sig.signal_type in COMPANY_SIGNAL_TYPES:
|
||||||
|
# Direction-aware: bullish contributes positively, bearish negatively
|
||||||
|
direction_sign = _direction_sign(sig.direction)
|
||||||
|
contribution = sig.confluence_score * direction_sign
|
||||||
|
s_company += contribution
|
||||||
|
weights.append({
|
||||||
|
"signal_type": sig.signal_type,
|
||||||
|
"layer": "company",
|
||||||
|
"confluence_score": sig.confluence_score,
|
||||||
|
"direction": sig.direction.value,
|
||||||
|
"contribution": contribution,
|
||||||
|
"active_timeframes": sig.active_timeframes,
|
||||||
|
})
|
||||||
|
|
||||||
|
return s_company, weights
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_s_macro(normalized: NormalizedInput) -> float:
|
||||||
|
"""Compute macro score from macro_bias.
|
||||||
|
|
||||||
|
S_macro = macro_bias * weight, where macro_bias is in [-1.0, 1.0].
|
||||||
|
A positive macro_bias contributes positively; negative contributes
|
||||||
|
negatively.
|
||||||
|
"""
|
||||||
|
return normalized.macro_bias * _MACRO_WEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_s_competitive(confluence_signals: list[ConfluenceSignal]) -> float:
|
||||||
|
"""Sum confluence scores for competitive-layer signals.
|
||||||
|
|
||||||
|
Currently returns 0.0 as no competitive signal types are defined in
|
||||||
|
the signal library. This is a placeholder for future expansion.
|
||||||
|
"""
|
||||||
|
s_competitive = 0.0
|
||||||
|
for sig in confluence_signals:
|
||||||
|
if sig.signal_type in COMPETITIVE_SIGNAL_TYPES:
|
||||||
|
direction_sign = _direction_sign(sig.direction)
|
||||||
|
s_competitive += sig.confluence_score * direction_sign
|
||||||
|
return s_competitive
|
||||||
|
|
||||||
|
|
||||||
|
def _direction_sign(direction: SignalDirection) -> float:
|
||||||
|
"""Map signal direction to a numeric sign."""
|
||||||
|
if direction == SignalDirection.BULLISH:
|
||||||
|
return 1.0
|
||||||
|
if direction == SignalDirection.BEARISH:
|
||||||
|
return -1.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Confidence computation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_confidence(
|
||||||
|
confluence_signals: list[ConfluenceSignal],
|
||||||
|
) -> float:
|
||||||
|
"""Compute pipeline confidence from confluence signals.
|
||||||
|
|
||||||
|
Confidence is derived from:
|
||||||
|
1. **Base confidence** — average signal strength across all confluence
|
||||||
|
signals (mean of confluence_score values).
|
||||||
|
2. **Source count boost** — more active signals increase confidence
|
||||||
|
(diminishing returns, capped contribution).
|
||||||
|
3. **Signal agreement boost** — if all signals point in the same
|
||||||
|
direction, confidence is boosted.
|
||||||
|
4. **Contradiction penalty** — if signals disagree on direction,
|
||||||
|
confidence is penalised.
|
||||||
|
|
||||||
|
Returns a value clamped to [0.0, 1.0].
|
||||||
|
"""
|
||||||
|
if not confluence_signals:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# 1. Base confidence: average confluence score (already weighted by
|
||||||
|
# timeframe importance)
|
||||||
|
total_score = sum(s.confluence_score for s in confluence_signals)
|
||||||
|
base_confidence = total_score / len(confluence_signals)
|
||||||
|
|
||||||
|
# 2. Source count factor: more signals → higher confidence, with
|
||||||
|
# diminishing returns. 1 signal → 0.6, 2 → 0.75, 3 → 0.85,
|
||||||
|
# 4 → 0.90, 5+ → 0.95 (asymptotic).
|
||||||
|
n = len(confluence_signals)
|
||||||
|
source_factor = 1.0 - (0.4 / n) # approaches 1.0 as n grows
|
||||||
|
|
||||||
|
# 3. Signal agreement / contradiction
|
||||||
|
directions = [s.direction for s in confluence_signals]
|
||||||
|
bullish_count = sum(1 for d in directions if d == SignalDirection.BULLISH)
|
||||||
|
bearish_count = sum(1 for d in directions if d == SignalDirection.BEARISH)
|
||||||
|
|
||||||
|
if n == 1:
|
||||||
|
agreement_factor = 1.0
|
||||||
|
elif bullish_count == n or bearish_count == n:
|
||||||
|
# Perfect agreement — boost
|
||||||
|
agreement_factor = 1.15
|
||||||
|
elif bullish_count > 0 and bearish_count > 0:
|
||||||
|
# Contradiction — penalty proportional to minority fraction
|
||||||
|
minority = min(bullish_count, bearish_count)
|
||||||
|
contradiction_ratio = minority / n
|
||||||
|
agreement_factor = 1.0 - (0.3 * contradiction_ratio)
|
||||||
|
else:
|
||||||
|
# Mix of directional and neutral — mild boost
|
||||||
|
agreement_factor = 1.05
|
||||||
|
|
||||||
|
confidence = base_confidence * source_factor * agreement_factor
|
||||||
|
return max(0.0, min(confidence, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verdict logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_verdict(
|
||||||
|
confidence: float,
|
||||||
|
s_total: float,
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
config: HeuristicConfig,
|
||||||
|
) -> tuple[Verdict, list[str]]:
|
||||||
|
"""Apply threshold logic to determine BUY / WATCH / SKIP verdict.
|
||||||
|
|
||||||
|
Returns the verdict and a list of reasoning strings explaining the
|
||||||
|
decision.
|
||||||
|
"""
|
||||||
|
reasoning: list[str] = []
|
||||||
|
|
||||||
|
valuation_score = normalized.valuation_score if normalized.valuation_score is not None else 0.0
|
||||||
|
earnings_days = normalized.earnings_proximity_days if normalized.earnings_proximity_days is not None else 0
|
||||||
|
|
||||||
|
# --- Check BUY conditions ---
|
||||||
|
buy_conditions = {
|
||||||
|
"confidence": confidence >= config.buy_confidence,
|
||||||
|
"s_total": s_total >= config.buy_s_total,
|
||||||
|
"valuation": valuation_score >= config.buy_valuation_min,
|
||||||
|
"macro_bias": normalized.macro_bias > config.macro_bias_threshold,
|
||||||
|
"earnings_proximity": earnings_days > config.earnings_days_threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
all_buy_met = all(buy_conditions.values())
|
||||||
|
|
||||||
|
if all_buy_met:
|
||||||
|
reasoning.append(
|
||||||
|
f"BUY: all conditions met — confidence={confidence:.3f} "
|
||||||
|
f"(>= {config.buy_confidence}), S_total={s_total:.3f} "
|
||||||
|
f"(>= {config.buy_s_total}), valuation={valuation_score:.2f} "
|
||||||
|
f"(>= {config.buy_valuation_min}), macro_bias={normalized.macro_bias:.2f} "
|
||||||
|
f"(> {config.macro_bias_threshold}), earnings_days={earnings_days} "
|
||||||
|
f"(> {config.earnings_days_threshold})"
|
||||||
|
)
|
||||||
|
return Verdict.BUY, reasoning
|
||||||
|
|
||||||
|
# --- Check WATCH conditions ---
|
||||||
|
if confidence >= config.watch_confidence:
|
||||||
|
# WATCH: confidence is sufficient but not all BUY conditions met
|
||||||
|
failed_conditions = [k for k, v in buy_conditions.items() if not v]
|
||||||
|
reasoning.append(
|
||||||
|
f"WATCH: confidence={confidence:.3f} (>= {config.watch_confidence}) "
|
||||||
|
f"but BUY conditions not fully met — failed: {', '.join(failed_conditions)}"
|
||||||
|
)
|
||||||
|
for cond_name, met in buy_conditions.items():
|
||||||
|
if not met:
|
||||||
|
reasoning.append(f" - {cond_name} not met")
|
||||||
|
return Verdict.WATCH, reasoning
|
||||||
|
|
||||||
|
# --- SKIP ---
|
||||||
|
reasoning.append(
|
||||||
|
f"SKIP: confidence={confidence:.3f} < {config.watch_confidence} "
|
||||||
|
f"(watch threshold)"
|
||||||
|
)
|
||||||
|
return Verdict.SKIP, reasoning
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_heuristic_pipeline(
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
confluence_signals: list[ConfluenceSignal],
|
||||||
|
config: HeuristicConfig,
|
||||||
|
) -> HeuristicResult:
|
||||||
|
"""Run the deterministic heuristic pipeline.
|
||||||
|
|
||||||
|
Computes ``S_total = S_company + S_macro + S_competitive`` using the
|
||||||
|
existing three-layer signal aggregation model and produces a
|
||||||
|
confidence-gated BUY / WATCH / SKIP verdict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
normalized: The unified input structure for this evaluation tick.
|
||||||
|
confluence_signals: Signals that passed multi-timeframe confluence
|
||||||
|
filtering.
|
||||||
|
config: Heuristic pipeline thresholds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`HeuristicResult` with verdict, scores, weights, and
|
||||||
|
reasoning.
|
||||||
|
|
||||||
|
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
|
||||||
|
"""
|
||||||
|
# 1. Compute three-layer scores
|
||||||
|
s_company, signal_weights = _compute_s_company(confluence_signals)
|
||||||
|
s_macro = _compute_s_macro(normalized)
|
||||||
|
s_competitive = _compute_s_competitive(confluence_signals)
|
||||||
|
s_total = s_company + s_macro + s_competitive
|
||||||
|
|
||||||
|
# 2. Compute confidence
|
||||||
|
confidence = _compute_confidence(confluence_signals)
|
||||||
|
|
||||||
|
# 3. Determine verdict
|
||||||
|
verdict, reasoning = _determine_verdict(confidence, s_total, normalized, config)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Heuristic pipeline [%s]: verdict=%s confidence=%.3f "
|
||||||
|
"S_total=%.3f (company=%.3f macro=%.3f competitive=%.3f) "
|
||||||
|
"signals=%d",
|
||||||
|
normalized.ticker,
|
||||||
|
verdict.value,
|
||||||
|
confidence,
|
||||||
|
s_total,
|
||||||
|
s_company,
|
||||||
|
s_macro,
|
||||||
|
s_competitive,
|
||||||
|
len(confluence_signals),
|
||||||
|
)
|
||||||
|
|
||||||
|
return HeuristicResult(
|
||||||
|
verdict=verdict,
|
||||||
|
confidence=confidence,
|
||||||
|
s_total=s_total,
|
||||||
|
s_company=s_company,
|
||||||
|
s_macro=s_macro,
|
||||||
|
s_competitive=s_competitive,
|
||||||
|
signal_weights=signal_weights,
|
||||||
|
reasoning=reasoning,
|
||||||
|
)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Signal engine entry point — asyncio event loop and queue polling.
|
||||||
|
|
||||||
|
Connects to PostgreSQL and Redis, loads configuration from ``risk_configs``,
|
||||||
|
and polls the ``stonks:queue:signal_engine`` queue indefinitely. Each
|
||||||
|
queue message triggers a full evaluation tick via ``evaluate_tick()``.
|
||||||
|
|
||||||
|
When ``dual_pipeline_enabled`` is ``False`` the worker sleeps and retries
|
||||||
|
(fail-safe: the existing pipeline continues unchanged).
|
||||||
|
|
||||||
|
Requirements: 13.1, 13.6, 13.7, 16.1, 16.6
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import redis.asyncio
|
||||||
|
|
||||||
|
from services.shared.config import load_config as load_app_config
|
||||||
|
from services.shared.redis_keys import QUEUE_SIGNAL_ENGINE, queue_key
|
||||||
|
from services.signal_engine.config import load_config as load_signal_config
|
||||||
|
from services.signal_engine.worker import evaluate_tick
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# BLPOP timeout in seconds — how long to wait for a queue message before
|
||||||
|
# looping back to check the enabled flag.
|
||||||
|
_BLPOP_TIMEOUT = 5
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Start the signal engine worker loop.
|
||||||
|
|
||||||
|
1. Connect to PostgreSQL (asyncpg pool) using env vars from
|
||||||
|
``services.shared.config``.
|
||||||
|
2. Connect to Redis (redis.asyncio) using env vars.
|
||||||
|
3. Load signal engine config via ``load_config(pool)``.
|
||||||
|
4. Log active configuration at startup.
|
||||||
|
5. Poll ``stonks:queue:signal_engine`` queue indefinitely (BLPOP).
|
||||||
|
6. Check ``dual_pipeline_enabled`` flag; if disabled, sleep and retry.
|
||||||
|
7. On config read failure, default to disabled (fail-safe).
|
||||||
|
8. Parse queue message as JSON: ``{"ticker": "AAPL", "triggered_at": "..."}``.
|
||||||
|
9. Call ``evaluate_tick(pool, redis, ticker, config)`` for each message.
|
||||||
|
|
||||||
|
Requirements: 13.1, 13.6, 13.7, 16.1, 16.6
|
||||||
|
"""
|
||||||
|
# --- Setup logging ---
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Signal engine starting up")
|
||||||
|
|
||||||
|
# --- Load shared app config for connection details ---
|
||||||
|
app_config = load_app_config()
|
||||||
|
|
||||||
|
# --- Connect to PostgreSQL ---
|
||||||
|
pool = await asyncpg.create_pool(
|
||||||
|
dsn=app_config.postgres.dsn,
|
||||||
|
min_size=2,
|
||||||
|
max_size=10,
|
||||||
|
)
|
||||||
|
logger.info("Connected to PostgreSQL at %s", app_config.postgres.host)
|
||||||
|
|
||||||
|
# --- Connect to Redis ---
|
||||||
|
redis_client = redis.asyncio.from_url(
|
||||||
|
app_config.redis.url,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
logger.info("Connected to Redis at %s", app_config.redis.host)
|
||||||
|
|
||||||
|
# --- Load signal engine config ---
|
||||||
|
try:
|
||||||
|
config = await load_signal_config(pool)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to load signal engine config at startup — "
|
||||||
|
"defaulting to disabled (fail-safe)",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
from services.signal_engine.config import SignalEngineConfig
|
||||||
|
config = SignalEngineConfig() # dual_pipeline_enabled=False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Signal engine config: dual_pipeline_enabled=%s, "
|
||||||
|
"heuristic=%s, probabilistic=%s, shadow_mode=%s, "
|
||||||
|
"polling_interval=%ds",
|
||||||
|
config.dual_pipeline_enabled,
|
||||||
|
config.heuristic_pipeline_enabled,
|
||||||
|
config.probabilistic_pipeline_enabled,
|
||||||
|
config.shadow_mode,
|
||||||
|
config.polling_interval_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Queue key ---
|
||||||
|
signal_queue = queue_key(QUEUE_SIGNAL_ENGINE)
|
||||||
|
logger.info("Polling queue: %s", signal_queue)
|
||||||
|
|
||||||
|
# --- Main loop ---
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Check if dual pipeline is enabled
|
||||||
|
if not config.dual_pipeline_enabled:
|
||||||
|
logger.debug(
|
||||||
|
"Dual pipeline disabled — sleeping %ds before retry",
|
||||||
|
config.polling_interval_seconds,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(config.polling_interval_seconds)
|
||||||
|
|
||||||
|
# Reload config to pick up flag changes
|
||||||
|
try:
|
||||||
|
config = await load_signal_config(pool)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to reload signal engine config — "
|
||||||
|
"keeping disabled (fail-safe)",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# BLPOP: blocking pop from the signal engine queue
|
||||||
|
try:
|
||||||
|
result = await redis_client.blpop(
|
||||||
|
signal_queue,
|
||||||
|
timeout=_BLPOP_TIMEOUT,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Redis BLPOP failed — sleeping before retry",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
# Timeout — no message, loop back
|
||||||
|
continue
|
||||||
|
|
||||||
|
# result is (queue_name, message)
|
||||||
|
_, raw_message = result
|
||||||
|
|
||||||
|
# Parse the queue message
|
||||||
|
try:
|
||||||
|
message = json.loads(raw_message)
|
||||||
|
ticker = message["ticker"]
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid queue message — skipping: %s",
|
||||||
|
raw_message,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("Processing evaluation tick for %s", ticker)
|
||||||
|
|
||||||
|
# Run the evaluation tick
|
||||||
|
try:
|
||||||
|
await evaluate_tick(pool, redis_client, ticker, config)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Unhandled error in evaluate_tick for %s",
|
||||||
|
ticker,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Signal engine shutting down (KeyboardInterrupt)")
|
||||||
|
finally:
|
||||||
|
await pool.close()
|
||||||
|
await redis_client.aclose()
|
||||||
|
logger.info("Signal engine shut down")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Pydantic data models for the dual-pipeline signal engine.
|
||||||
|
|
||||||
|
Defines all input, intermediate, and output models consumed by the heuristic
|
||||||
|
pipeline, probabilistic pipeline, delta analyzer, exit engine, and output
|
||||||
|
formatter. Every model is a Pydantic ``BaseModel`` subclass with field-level
|
||||||
|
constraints where applicable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Market data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class OHLCVBar(BaseModel):
|
||||||
|
"""Single OHLCV bar for a timeframe."""
|
||||||
|
|
||||||
|
timestamp: datetime
|
||||||
|
open: float
|
||||||
|
high: float
|
||||||
|
low: float
|
||||||
|
close: float
|
||||||
|
volume: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Position state (for exit engine)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class OpenPositionState(BaseModel):
|
||||||
|
"""Snapshot of an open position for exit evaluation."""
|
||||||
|
|
||||||
|
position_id: str
|
||||||
|
ticker: str
|
||||||
|
entry_price: float
|
||||||
|
current_price: float
|
||||||
|
stop_loss: float
|
||||||
|
target_1: float
|
||||||
|
target_2: float
|
||||||
|
trailing_stop: float | None = None
|
||||||
|
partial_exit_done: bool = False
|
||||||
|
atr: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Normalized input consumed by both pipelines
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizedInput(BaseModel):
|
||||||
|
"""Unified input structure consumed by both pipelines."""
|
||||||
|
|
||||||
|
ticker: str
|
||||||
|
evaluated_at: datetime
|
||||||
|
|
||||||
|
# Multi-timeframe OHLCV bars keyed by timeframe label
|
||||||
|
bars: dict[str, list[OHLCVBar]] # {"M30": [...], "H1": [...], ...}
|
||||||
|
|
||||||
|
# Fundamental / macro context
|
||||||
|
valuation_score: float | None = None # [0.0, 1.0]
|
||||||
|
earnings_proximity_days: int | None = None
|
||||||
|
macro_bias: float = 0.0 # [-1.0, 1.0]
|
||||||
|
|
||||||
|
# Open positions for exit evaluation
|
||||||
|
open_positions: list[OpenPositionState] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Price series helpers (used by probabilistic pipeline)
|
||||||
|
closing_prices: list[float] = Field(default_factory=list)
|
||||||
|
returns: list[float] = Field(default_factory=list)
|
||||||
|
current_price: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal evaluation primitives
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SignalDirection(str, Enum):
|
||||||
|
BULLISH = "bullish"
|
||||||
|
BEARISH = "bearish"
|
||||||
|
NEUTRAL = "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
class SignalResult(BaseModel):
|
||||||
|
"""Output from a single signal evaluator on a single timeframe."""
|
||||||
|
|
||||||
|
signal_type: str
|
||||||
|
timeframe: str
|
||||||
|
strength: float = Field(ge=0.0, le=1.0)
|
||||||
|
direction: SignalDirection
|
||||||
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
metadata: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Multi-timeframe confluence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ConfluenceSignal(BaseModel):
|
||||||
|
"""A signal that passed multi-timeframe confluence filtering."""
|
||||||
|
|
||||||
|
signal_type: str
|
||||||
|
direction: SignalDirection
|
||||||
|
confluence_score: float
|
||||||
|
active_timeframes: list[str]
|
||||||
|
per_timeframe: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pipeline verdicts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Verdict(str, Enum):
|
||||||
|
BUY = "BUY"
|
||||||
|
WATCH = "WATCH"
|
||||||
|
SKIP = "SKIP"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Heuristic pipeline output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HeuristicResult(BaseModel):
|
||||||
|
"""Output from the heuristic (deterministic) pipeline."""
|
||||||
|
|
||||||
|
verdict: Verdict
|
||||||
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
s_total: float
|
||||||
|
s_company: float
|
||||||
|
s_macro: float
|
||||||
|
s_competitive: float
|
||||||
|
signal_weights: list[dict] = Field(default_factory=list)
|
||||||
|
reasoning: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Probabilistic pipeline output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LikelihoodRatio(BaseModel):
|
||||||
|
"""A single signal's likelihood ratio for Bayesian updating."""
|
||||||
|
|
||||||
|
signal_type: str
|
||||||
|
cluster: str
|
||||||
|
lr: float
|
||||||
|
log_lr: float
|
||||||
|
penalized_log_lr: float
|
||||||
|
hit_rate: float
|
||||||
|
strength: float
|
||||||
|
|
||||||
|
|
||||||
|
class ProbabilisticResult(BaseModel):
|
||||||
|
"""Output from the probabilistic (Bayesian) pipeline."""
|
||||||
|
|
||||||
|
verdict: Verdict
|
||||||
|
p_up: float = Field(ge=0.0, le=1.0)
|
||||||
|
entropy: float = Field(ge=0.0, le=1.0)
|
||||||
|
ev_r: float
|
||||||
|
prior: float
|
||||||
|
posterior: float
|
||||||
|
likelihood_ratios: list[LikelihoodRatio] = Field(default_factory=list)
|
||||||
|
regime: str
|
||||||
|
reasoning: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delta analyzer output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class DeltaResult(BaseModel):
|
||||||
|
"""Output from the delta analyzer comparing both pipelines."""
|
||||||
|
|
||||||
|
agreement: bool
|
||||||
|
confidence_delta: float
|
||||||
|
heuristic_verdict: str
|
||||||
|
probabilistic_verdict: str
|
||||||
|
disagreement_reasons: list[str] = Field(default_factory=list)
|
||||||
|
rolling_agreement_rate: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exit engine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ExitType(str, Enum):
|
||||||
|
EXIT_HALF = "EXIT_HALF"
|
||||||
|
EXIT_FULL = "EXIT_FULL"
|
||||||
|
|
||||||
|
|
||||||
|
class ExitSignal(BaseModel):
|
||||||
|
"""An exit signal for an open position."""
|
||||||
|
|
||||||
|
position_id: str
|
||||||
|
ticker: str
|
||||||
|
exit_type: ExitType
|
||||||
|
reason: str
|
||||||
|
price: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trade plan
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TradePlan(BaseModel):
|
||||||
|
"""Optional trade plan attached to a BUY signal."""
|
||||||
|
|
||||||
|
entry_price: float
|
||||||
|
stop_loss: float
|
||||||
|
target_1: float
|
||||||
|
target_2: float
|
||||||
|
position_size_pct: float = Field(ge=0.0, le=1.0)
|
||||||
|
max_loss_pct: float = Field(ge=0.0, le=1.0)
|
||||||
|
dual_confirmed: bool = False
|
||||||
|
probabilistic_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Structured output contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SignalOutput(BaseModel):
|
||||||
|
"""The structured output contract consumed by the trading engine and audit systems."""
|
||||||
|
|
||||||
|
output_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
ticker: str
|
||||||
|
timestamp: datetime
|
||||||
|
price: float
|
||||||
|
|
||||||
|
# Heuristic pipeline section
|
||||||
|
heuristic_verdict: str
|
||||||
|
heuristic_confidence: float
|
||||||
|
heuristic_s_total: float
|
||||||
|
|
||||||
|
# Probabilistic pipeline section
|
||||||
|
probabilistic_verdict: str
|
||||||
|
probabilistic_p_up: float
|
||||||
|
probabilistic_entropy: float
|
||||||
|
probabilistic_ev_r: float
|
||||||
|
|
||||||
|
# Delta analysis section
|
||||||
|
delta_agreement: bool
|
||||||
|
delta_confidence_delta: float
|
||||||
|
delta_reasons: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Optional trade plan and exit signals
|
||||||
|
trade_plan: TradePlan | None = None
|
||||||
|
exit_signals: list[ExitSignal] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Detail payloads for audit / dashboard
|
||||||
|
heuristic_detail: dict = Field(default_factory=dict)
|
||||||
|
probabilistic_detail: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Pipeline mode metadata
|
||||||
|
pipeline_mode: str = "dual_pipeline"
|
||||||
|
shadow_mode: bool = False
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
"""Input Normalizer — fetches and assembles NormalizedInput for a single tick.
|
||||||
|
|
||||||
|
Queries multiple data sources (market snapshots, trend windows, earnings
|
||||||
|
calendar, macro impact records, position stop levels) and assembles them
|
||||||
|
into a single ``NormalizedInput`` consumed by both pipelines.
|
||||||
|
|
||||||
|
Missing data sources produce sentinel values (``None`` / empty list) with a
|
||||||
|
logged warning — the normalizer never crashes on unavailable data.
|
||||||
|
|
||||||
|
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from .config import SignalEngineConfig
|
||||||
|
from .models import NormalizedInput, OHLCVBar, OpenPositionState
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Timeframes the signal engine evaluates, ordered shortest → longest.
|
||||||
|
TIMEFRAMES = ("M30", "H1", "H4", "D", "W", "M")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Direction → numeric bias mapping (same semantics as aggregation worker)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_DIRECTION_TO_BIAS: dict[str, float] = {
|
||||||
|
"positive": 1.0,
|
||||||
|
"negative": -1.0,
|
||||||
|
"mixed": 0.0,
|
||||||
|
"neutral": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_monotonic_timestamps(
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
timeframe: str,
|
||||||
|
ticker: str,
|
||||||
|
) -> list[OHLCVBar]:
|
||||||
|
"""Return *bars* sorted by timestamp, warning on non-monotonic input.
|
||||||
|
|
||||||
|
If timestamps are already strictly increasing the list is returned
|
||||||
|
unchanged. Otherwise the bars are sorted and a warning is logged.
|
||||||
|
"""
|
||||||
|
if len(bars) <= 1:
|
||||||
|
return bars
|
||||||
|
|
||||||
|
is_monotonic = all(
|
||||||
|
bars[i].timestamp < bars[i + 1].timestamp for i in range(len(bars) - 1)
|
||||||
|
)
|
||||||
|
if is_monotonic:
|
||||||
|
return bars
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"%s/%s: OHLCV timestamps not monotonically increasing — sorting",
|
||||||
|
ticker,
|
||||||
|
timeframe,
|
||||||
|
)
|
||||||
|
return sorted(bars, key=lambda b: b.timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def _polygon_bar_to_ohlcv(row: asyncpg.Record) -> OHLCVBar | None:
|
||||||
|
"""Convert a market_snapshots row (JSONB data column) to an OHLCVBar.
|
||||||
|
|
||||||
|
Polygon bar format stored in ``data``:
|
||||||
|
t — timestamp in epoch milliseconds
|
||||||
|
o — open
|
||||||
|
h — high
|
||||||
|
l — low
|
||||||
|
c — close
|
||||||
|
v — volume
|
||||||
|
|
||||||
|
Returns ``None`` if the row cannot be parsed.
|
||||||
|
"""
|
||||||
|
data = row["data"]
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ts_ms = data.get("t")
|
||||||
|
if ts_ms is None:
|
||||||
|
return None
|
||||||
|
return OHLCVBar(
|
||||||
|
timestamp=datetime.fromtimestamp(int(ts_ms) / 1000, tz=timezone.utc),
|
||||||
|
open=float(data.get("o", 0)),
|
||||||
|
high=float(data.get("h", 0)),
|
||||||
|
low=float(data.get("l", 0)),
|
||||||
|
close=float(data.get("c", 0)),
|
||||||
|
volume=float(data.get("v", 0)),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data-source fetchers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_bars(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
ticker: str,
|
||||||
|
) -> dict[str, list[OHLCVBar]]:
|
||||||
|
"""Fetch OHLCV bars from ``market_snapshots`` for all timeframes.
|
||||||
|
|
||||||
|
The current database stores daily bars (``snapshot_type = 'bar'``) from
|
||||||
|
Polygon. Intraday bars are stored with ``snapshot_type = 'intraday_bar'``
|
||||||
|
when available.
|
||||||
|
|
||||||
|
For timeframes that have no dedicated data yet (H4, W, M) we derive them
|
||||||
|
from daily bars where possible:
|
||||||
|
- **W** (weekly): group daily bars by ISO week.
|
||||||
|
- **M** (monthly): group daily bars by calendar month.
|
||||||
|
- **H4 / H1 / M30**: sourced from intraday snapshots when present;
|
||||||
|
otherwise left empty.
|
||||||
|
|
||||||
|
Returns a dict keyed by timeframe label with validated bar lists.
|
||||||
|
"""
|
||||||
|
bars: dict[str, list[OHLCVBar]] = {tf: [] for tf in TIMEFRAMES}
|
||||||
|
|
||||||
|
# --- Daily bars --------------------------------------------------------
|
||||||
|
try:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT data FROM market_snapshots "
|
||||||
|
"WHERE ticker = $1 AND snapshot_type = 'bar' "
|
||||||
|
"ORDER BY captured_at ASC",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
daily: list[OHLCVBar] = []
|
||||||
|
for row in rows:
|
||||||
|
bar = _polygon_bar_to_ohlcv(row)
|
||||||
|
if bar is not None:
|
||||||
|
daily.append(bar)
|
||||||
|
bars["D"] = daily
|
||||||
|
except Exception:
|
||||||
|
logger.warning("%s: failed to fetch daily bars", ticker, exc_info=True)
|
||||||
|
|
||||||
|
# --- Intraday bars (M30, H1) ------------------------------------------
|
||||||
|
try:
|
||||||
|
intraday_rows = await pool.fetch(
|
||||||
|
"SELECT data FROM market_snapshots "
|
||||||
|
"WHERE ticker = $1 AND snapshot_type = 'intraday_bar' "
|
||||||
|
"ORDER BY captured_at ASC",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
intraday: list[OHLCVBar] = []
|
||||||
|
for row in intraday_rows:
|
||||||
|
bar = _polygon_bar_to_ohlcv(row)
|
||||||
|
if bar is not None:
|
||||||
|
intraday.append(bar)
|
||||||
|
|
||||||
|
# Assign intraday bars to M30 and H1 buckets.
|
||||||
|
# The actual timespan depends on the source config; we store them
|
||||||
|
# under M30 (shortest) and duplicate to H1 for now. When dedicated
|
||||||
|
# H1 bars are ingested they will replace this.
|
||||||
|
if intraday:
|
||||||
|
bars["M30"] = intraday
|
||||||
|
bars["H1"] = intraday
|
||||||
|
except Exception:
|
||||||
|
logger.warning("%s: failed to fetch intraday bars", ticker, exc_info=True)
|
||||||
|
|
||||||
|
# --- Derive H4 from intraday (4-hour grouping) ------------------------
|
||||||
|
# Left empty when no intraday data — sentinel value per Req 1.3.
|
||||||
|
|
||||||
|
# --- Derive weekly bars from daily ------------------------------------
|
||||||
|
if bars["D"]:
|
||||||
|
bars["W"] = _aggregate_bars_by_period(bars["D"], period="week")
|
||||||
|
|
||||||
|
# --- Derive monthly bars from daily -----------------------------------
|
||||||
|
if bars["D"]:
|
||||||
|
bars["M"] = _aggregate_bars_by_period(bars["D"], period="month")
|
||||||
|
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_bars_by_period(
|
||||||
|
daily_bars: list[OHLCVBar],
|
||||||
|
period: str,
|
||||||
|
) -> list[OHLCVBar]:
|
||||||
|
"""Aggregate daily bars into weekly or monthly bars.
|
||||||
|
|
||||||
|
Groups by ISO week (period="week") or calendar month (period="month"),
|
||||||
|
then computes OHLCV aggregates per group.
|
||||||
|
"""
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
groups: OrderedDict[tuple[int, int], list[OHLCVBar]] = OrderedDict()
|
||||||
|
for bar in daily_bars:
|
||||||
|
if period == "week":
|
||||||
|
iso = bar.timestamp.isocalendar()
|
||||||
|
key = (iso[0], iso[1]) # (year, week)
|
||||||
|
else:
|
||||||
|
key = (bar.timestamp.year, bar.timestamp.month)
|
||||||
|
groups.setdefault(key, []).append(bar)
|
||||||
|
|
||||||
|
result: list[OHLCVBar] = []
|
||||||
|
for group_bars in groups.values():
|
||||||
|
if not group_bars:
|
||||||
|
continue
|
||||||
|
result.append(
|
||||||
|
OHLCVBar(
|
||||||
|
timestamp=group_bars[0].timestamp, # period open timestamp
|
||||||
|
open=group_bars[0].open,
|
||||||
|
high=max(b.high for b in group_bars),
|
||||||
|
low=min(b.low for b in group_bars),
|
||||||
|
close=group_bars[-1].close,
|
||||||
|
volume=sum(b.volume for b in group_bars),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_fundamentals(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
ticker: str,
|
||||||
|
) -> tuple[float | None, int | None]:
|
||||||
|
"""Fetch valuation_score and earnings_proximity_days.
|
||||||
|
|
||||||
|
- **valuation_score**: derived from the latest ``trend_windows`` confidence
|
||||||
|
for the ticker (entity_type='company', entity_id=ticker).
|
||||||
|
- **earnings_proximity_days**: days until the next earnings date from
|
||||||
|
``earnings_calendar``.
|
||||||
|
|
||||||
|
Returns ``(valuation_score, earnings_proximity_days)`` with ``None``
|
||||||
|
sentinels for unavailable data.
|
||||||
|
"""
|
||||||
|
valuation_score: float | None = None
|
||||||
|
earnings_proximity_days: int | None = None
|
||||||
|
|
||||||
|
# --- Valuation score from trend_windows --------------------------------
|
||||||
|
try:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT confidence FROM trend_windows "
|
||||||
|
"WHERE entity_type = 'company' AND entity_id = $1 "
|
||||||
|
"ORDER BY generated_at DESC LIMIT 1",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
valuation_score = float(row["confidence"])
|
||||||
|
else:
|
||||||
|
logger.warning("%s: no trend_windows data — valuation_score=None", ticker)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"%s: failed to fetch valuation_score", ticker, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Earnings proximity from earnings_calendar -------------------------
|
||||||
|
try:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT earnings_date FROM earnings_calendar "
|
||||||
|
"WHERE ticker = $1 AND earnings_date >= CURRENT_DATE "
|
||||||
|
"ORDER BY earnings_date ASC LIMIT 1",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
delta = row["earnings_date"] - datetime.now(timezone.utc).date()
|
||||||
|
earnings_proximity_days = delta.days
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"%s: no upcoming earnings in calendar — earnings_proximity_days=None",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"%s: failed to fetch earnings_proximity_days", ticker, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return valuation_score, earnings_proximity_days
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_macro_bias(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
ticker: str,
|
||||||
|
) -> float:
|
||||||
|
"""Compute macro_bias for *ticker* from recent ``macro_impact_records``.
|
||||||
|
|
||||||
|
Averages the numeric bias of the most recent impact records (up to 10)
|
||||||
|
weighted by their confidence. The direction string is mapped to a float
|
||||||
|
via ``_DIRECTION_TO_BIAS``.
|
||||||
|
|
||||||
|
Returns 0.0 (neutral) when no records are found or on error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT impact_direction, macro_impact_score, confidence "
|
||||||
|
"FROM macro_impact_records "
|
||||||
|
"WHERE ticker = $1 "
|
||||||
|
"ORDER BY computed_at DESC LIMIT 10",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
logger.warning("%s: no macro_impact_records — macro_bias=0.0", ticker)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
weighted_sum = 0.0
|
||||||
|
weight_total = 0.0
|
||||||
|
for row in rows:
|
||||||
|
direction = row["impact_direction"] or "neutral"
|
||||||
|
bias = _DIRECTION_TO_BIAS.get(direction, 0.0)
|
||||||
|
score = float(row["macro_impact_score"] or 0.0)
|
||||||
|
conf = float(row["confidence"] or 0.5)
|
||||||
|
w = score * conf
|
||||||
|
weighted_sum += bias * w
|
||||||
|
weight_total += w
|
||||||
|
|
||||||
|
if weight_total == 0.0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Clamp to [-1.0, 1.0]
|
||||||
|
raw = weighted_sum / weight_total
|
||||||
|
return max(-1.0, min(1.0, raw))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("%s: failed to fetch macro_bias", ticker, exc_info=True)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_open_positions(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
ticker: str,
|
||||||
|
) -> list[OpenPositionState]:
|
||||||
|
"""Fetch open positions for *ticker* from ``position_stop_levels``.
|
||||||
|
|
||||||
|
Joins with ``positions`` for current_price when available.
|
||||||
|
Returns an empty list on error or when no positions exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT psl.id, psl.ticker, psl.entry_price, "
|
||||||
|
" psl.stop_loss_price, psl.take_profit_price, "
|
||||||
|
" psl.trailing_stop_active, psl.atr_value, "
|
||||||
|
" psl.atr_multiplier, psl.reward_risk_ratio, "
|
||||||
|
" COALESCE(p.current_price, psl.entry_price) AS current_price "
|
||||||
|
"FROM position_stop_levels psl "
|
||||||
|
"LEFT JOIN positions p ON p.ticker = psl.ticker "
|
||||||
|
"WHERE psl.ticker = $1 AND psl.active = TRUE",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
positions: list[OpenPositionState] = []
|
||||||
|
for row in rows:
|
||||||
|
entry = float(row["entry_price"])
|
||||||
|
current = float(row["current_price"])
|
||||||
|
stop = float(row["stop_loss_price"])
|
||||||
|
tp = float(row["take_profit_price"])
|
||||||
|
atr = float(row["atr_value"]) if row["atr_value"] else None
|
||||||
|
rr = float(row["reward_risk_ratio"]) if row["reward_risk_ratio"] else 2.0
|
||||||
|
|
||||||
|
# Derive target_2 from reward-risk ratio if only one TP level
|
||||||
|
target_1 = tp
|
||||||
|
target_2 = entry + (tp - entry) * rr if rr > 1.0 else tp
|
||||||
|
|
||||||
|
positions.append(
|
||||||
|
OpenPositionState(
|
||||||
|
position_id=str(row["id"]),
|
||||||
|
ticker=row["ticker"],
|
||||||
|
entry_price=entry,
|
||||||
|
current_price=current,
|
||||||
|
stop_loss=stop,
|
||||||
|
target_1=target_1,
|
||||||
|
target_2=target_2,
|
||||||
|
trailing_stop=None, # computed by exit engine at runtime
|
||||||
|
partial_exit_done=bool(row["trailing_stop_active"]),
|
||||||
|
atr=atr,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return positions
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"%s: failed to fetch open positions", ticker, exc_info=True
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def normalize_input(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
ticker: str,
|
||||||
|
config: SignalEngineConfig,
|
||||||
|
) -> NormalizedInput:
|
||||||
|
"""Fetch and assemble all data needed for a single evaluation tick.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- OHLCV bars from ``market_snapshots`` (M30, H1, H4, D, W, M)
|
||||||
|
- Fundamental metrics from ``trend_windows`` + ``earnings_calendar``
|
||||||
|
- Macro context from ``macro_impact_records``
|
||||||
|
- Open position state from ``position_stop_levels`` + ``positions``
|
||||||
|
|
||||||
|
Missing data sources produce sentinel values (``None`` / empty list)
|
||||||
|
with a logged warning. The function never raises — it always returns
|
||||||
|
a valid ``NormalizedInput``.
|
||||||
|
|
||||||
|
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Fetch all data sources concurrently for efficiency.
|
||||||
|
# Each fetcher handles its own errors and returns sentinels on failure.
|
||||||
|
|
||||||
|
bars_task = asyncio.create_task(_fetch_bars(pool, ticker))
|
||||||
|
fundamentals_task = asyncio.create_task(_fetch_fundamentals(pool, ticker))
|
||||||
|
macro_task = asyncio.create_task(_fetch_macro_bias(pool, ticker))
|
||||||
|
positions_task = asyncio.create_task(_fetch_open_positions(pool, ticker))
|
||||||
|
|
||||||
|
bars = await bars_task
|
||||||
|
valuation_score, earnings_proximity_days = await fundamentals_task
|
||||||
|
macro_bias = await macro_task
|
||||||
|
open_positions = await positions_task
|
||||||
|
|
||||||
|
# Validate monotonic timestamps within each timeframe (Req 1.4)
|
||||||
|
for tf in TIMEFRAMES:
|
||||||
|
bars[tf] = _validate_monotonic_timestamps(bars[tf], tf, ticker)
|
||||||
|
|
||||||
|
# Compute closing_prices and returns from daily bars for regime
|
||||||
|
# classification (used by the probabilistic pipeline).
|
||||||
|
closing_prices: list[float] = []
|
||||||
|
returns: list[float] = []
|
||||||
|
daily = bars.get("D", [])
|
||||||
|
if daily:
|
||||||
|
closing_prices = [bar.close for bar in daily]
|
||||||
|
if len(closing_prices) >= 2:
|
||||||
|
returns = [
|
||||||
|
(closing_prices[i] - closing_prices[i - 1]) / closing_prices[i - 1]
|
||||||
|
if closing_prices[i - 1] != 0
|
||||||
|
else 0.0
|
||||||
|
for i in range(1, len(closing_prices))
|
||||||
|
]
|
||||||
|
|
||||||
|
# Determine current_price from the latest close of the shortest
|
||||||
|
# available timeframe.
|
||||||
|
current_price: float | None = None
|
||||||
|
for tf in TIMEFRAMES: # shortest first
|
||||||
|
if bars[tf]:
|
||||||
|
current_price = bars[tf][-1].close
|
||||||
|
break
|
||||||
|
|
||||||
|
return NormalizedInput(
|
||||||
|
ticker=ticker,
|
||||||
|
evaluated_at=now,
|
||||||
|
bars=bars,
|
||||||
|
valuation_score=valuation_score,
|
||||||
|
earnings_proximity_days=earnings_proximity_days,
|
||||||
|
macro_bias=macro_bias,
|
||||||
|
open_positions=open_positions,
|
||||||
|
closing_prices=closing_prices,
|
||||||
|
returns=returns,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Database persistence for signal engine outputs.
|
||||||
|
|
||||||
|
Persists ``SignalOutput`` instances to the ``signal_engine_outputs`` table.
|
||||||
|
Persistence failures are logged and swallowed — they never block signal
|
||||||
|
emission to the trading queue.
|
||||||
|
|
||||||
|
Requirements: 15.1, 15.4
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from services.signal_engine.models import SignalOutput
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# INSERT statement for the signal_engine_outputs table.
|
||||||
|
_INSERT_SQL = """
|
||||||
|
INSERT INTO signal_engine_outputs (
|
||||||
|
id,
|
||||||
|
ticker,
|
||||||
|
evaluated_at,
|
||||||
|
price,
|
||||||
|
heuristic_verdict,
|
||||||
|
heuristic_confidence,
|
||||||
|
heuristic_s_total,
|
||||||
|
probabilistic_verdict,
|
||||||
|
probabilistic_p_up,
|
||||||
|
probabilistic_entropy,
|
||||||
|
probabilistic_ev_r,
|
||||||
|
delta_agreement,
|
||||||
|
delta_confidence_delta,
|
||||||
|
delta_reasons,
|
||||||
|
trade_plan,
|
||||||
|
full_output,
|
||||||
|
exit_signals,
|
||||||
|
pipeline_mode,
|
||||||
|
shadow_mode
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
|
$11, $12, $13, $14, $15, $16, $17, $18, $19
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_signal_output(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
output: SignalOutput,
|
||||||
|
) -> None:
|
||||||
|
"""Persist a SignalOutput to the signal_engine_outputs table.
|
||||||
|
|
||||||
|
Logs and continues on database errors (non-blocking).
|
||||||
|
|
||||||
|
Requirements: 15.1, 15.4
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
trade_plan_json: str | None = None
|
||||||
|
if output.trade_plan is not None:
|
||||||
|
trade_plan_json = json.dumps(output.trade_plan.model_dump())
|
||||||
|
|
||||||
|
exit_signals_json = json.dumps(
|
||||||
|
[e.model_dump() for e in output.exit_signals]
|
||||||
|
)
|
||||||
|
|
||||||
|
delta_reasons_json = json.dumps(output.delta_reasons)
|
||||||
|
|
||||||
|
full_output_json = output.model_dump_json()
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
_INSERT_SQL,
|
||||||
|
output.output_id, # $1 id
|
||||||
|
output.ticker, # $2 ticker
|
||||||
|
output.timestamp, # $3 evaluated_at
|
||||||
|
output.price, # $4 price
|
||||||
|
output.heuristic_verdict, # $5 heuristic_verdict
|
||||||
|
output.heuristic_confidence, # $6 heuristic_confidence
|
||||||
|
output.heuristic_s_total, # $7 heuristic_s_total
|
||||||
|
output.probabilistic_verdict, # $8 probabilistic_verdict
|
||||||
|
output.probabilistic_p_up, # $9 probabilistic_p_up
|
||||||
|
output.probabilistic_entropy, # $10 probabilistic_entropy
|
||||||
|
output.probabilistic_ev_r, # $11 probabilistic_ev_r
|
||||||
|
output.delta_agreement, # $12 delta_agreement
|
||||||
|
output.delta_confidence_delta, # $13 delta_confidence_delta
|
||||||
|
delta_reasons_json, # $14 delta_reasons (JSONB)
|
||||||
|
trade_plan_json, # $15 trade_plan (JSONB, nullable)
|
||||||
|
full_output_json, # $16 full_output (JSONB)
|
||||||
|
exit_signals_json, # $17 exit_signals (JSONB)
|
||||||
|
output.pipeline_mode, # $18 pipeline_mode
|
||||||
|
output.shadow_mode, # $19 shadow_mode
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Persisted signal output %s for %s",
|
||||||
|
output.output_id,
|
||||||
|
output.ticker,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Failed to persist signal output %s for %s — continuing",
|
||||||
|
output.output_id,
|
||||||
|
output.ticker,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
"""Probabilistic Pipeline (Pipeline B) — Bayesian inference and verdict.
|
||||||
|
|
||||||
|
Computes a posterior probability via regime-based priors, likelihood ratio
|
||||||
|
accumulation with correlation penalty, entropy gating, and expected value
|
||||||
|
calculation. Produces a BUY / WATCH / SKIP verdict.
|
||||||
|
|
||||||
|
The pipeline reuses the existing ``classify_regime`` infrastructure from
|
||||||
|
``services.aggregation.regime`` for regime classification and wraps the
|
||||||
|
Bayesian math with signal-cluster correlation penalties from
|
||||||
|
``services.signal_engine.correlation``.
|
||||||
|
|
||||||
|
Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9,
|
||||||
|
14.1, 14.2, 14.3, 14.4, 14.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
from services.aggregation.regime import MarketRegime, RegimeClassification
|
||||||
|
from services.signal_engine.config import ProbabilisticConfig
|
||||||
|
from services.signal_engine.correlation import (
|
||||||
|
apply_correlation_penalty,
|
||||||
|
classify_signal,
|
||||||
|
)
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
ConfluenceSignal,
|
||||||
|
LikelihoodRatio,
|
||||||
|
NormalizedInput,
|
||||||
|
ProbabilisticResult,
|
||||||
|
SignalDirection,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default hit rate used when no historical hit rate is available.
|
||||||
|
_DEFAULT_HIT_RATE: float = 0.6
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Regime → prior mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _regime_to_prior(
|
||||||
|
regime: RegimeClassification,
|
||||||
|
config: ProbabilisticConfig,
|
||||||
|
) -> float:
|
||||||
|
"""Map a regime classification to a prior probability.
|
||||||
|
|
||||||
|
Mapping (Req 14.2):
|
||||||
|
- TREND_FOLLOWING with positive trend_indicator → bull prior (0.58)
|
||||||
|
- TREND_FOLLOWING with negative trend_indicator → bear prior (0.42)
|
||||||
|
- MEAN_REVERSION → range prior (0.50)
|
||||||
|
- PANIC → bear prior (0.42)
|
||||||
|
- UNCERTAINTY → range prior (0.50)
|
||||||
|
"""
|
||||||
|
if regime.regime == MarketRegime.TREND_FOLLOWING:
|
||||||
|
if regime.trend_indicator > 0:
|
||||||
|
return config.regime_prior_bull
|
||||||
|
return config.regime_prior_bear
|
||||||
|
if regime.regime == MarketRegime.MEAN_REVERSION:
|
||||||
|
return config.regime_prior_range
|
||||||
|
if regime.regime == MarketRegime.PANIC:
|
||||||
|
return config.regime_prior_bear
|
||||||
|
# UNCERTAINTY or any unknown → range prior
|
||||||
|
return config.regime_prior_range
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Likelihood ratio computation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_likelihood_ratios(
|
||||||
|
confluence_signals: list[ConfluenceSignal],
|
||||||
|
) -> list[LikelihoodRatio]:
|
||||||
|
"""Compute raw likelihood ratios for each confluence signal.
|
||||||
|
|
||||||
|
For each signal:
|
||||||
|
- h = hit rate (use confidence as proxy, default 0.6)
|
||||||
|
- s = signal strength (confluence_score)
|
||||||
|
- P(sig|up) = h * s + (1 - h) * (1 - s) * 0.5
|
||||||
|
- P(sig|down) = 1 - P(sig|up)
|
||||||
|
- LR = P(sig|up) / P(sig|down)
|
||||||
|
|
||||||
|
Direction-aware: bearish signals invert the LR (use 1/LR) so that
|
||||||
|
bearish evidence reduces P_up.
|
||||||
|
|
||||||
|
Requirements: 6.2
|
||||||
|
"""
|
||||||
|
ratios: list[LikelihoodRatio] = []
|
||||||
|
|
||||||
|
for sig in confluence_signals:
|
||||||
|
h = _DEFAULT_HIT_RATE
|
||||||
|
s = sig.confluence_score
|
||||||
|
|
||||||
|
# Clamp inputs to valid ranges to avoid numerical issues
|
||||||
|
h = max(0.01, min(h, 0.99))
|
||||||
|
s = max(0.01, min(s, 0.99))
|
||||||
|
|
||||||
|
p_sig_up = h * s + (1.0 - h) * (1.0 - s) * 0.5
|
||||||
|
p_sig_down = 1.0 - p_sig_up
|
||||||
|
|
||||||
|
# Guard against division by zero / near-zero
|
||||||
|
if p_sig_down < 1e-10:
|
||||||
|
p_sig_down = 1e-10
|
||||||
|
|
||||||
|
lr = p_sig_up / p_sig_down
|
||||||
|
|
||||||
|
# Bearish signals: invert the LR so it reduces P_up
|
||||||
|
if sig.direction == SignalDirection.BEARISH:
|
||||||
|
lr = 1.0 / lr if lr > 1e-10 else 1e10
|
||||||
|
|
||||||
|
log_lr = math.log(lr) if lr > 0 else 0.0
|
||||||
|
|
||||||
|
cluster = classify_signal(sig.signal_type)
|
||||||
|
|
||||||
|
ratios.append(
|
||||||
|
LikelihoodRatio(
|
||||||
|
signal_type=sig.signal_type,
|
||||||
|
cluster=cluster.value,
|
||||||
|
lr=lr,
|
||||||
|
log_lr=log_lr,
|
||||||
|
penalized_log_lr=log_lr, # will be updated by penalty
|
||||||
|
hit_rate=h,
|
||||||
|
strength=s,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ratios
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log-odds / sigmoid helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _logit(p: float) -> float:
|
||||||
|
"""Compute logit(p) = log(p / (1 - p)).
|
||||||
|
|
||||||
|
Clamps p to (1e-10, 1 - 1e-10) to avoid infinities.
|
||||||
|
"""
|
||||||
|
p = max(1e-10, min(p, 1.0 - 1e-10))
|
||||||
|
return math.log(p / (1.0 - p))
|
||||||
|
|
||||||
|
|
||||||
|
def _sigmoid(x: float) -> float:
|
||||||
|
"""Compute sigmoid(x) = 1 / (1 + exp(-x)).
|
||||||
|
|
||||||
|
Clamps the exponent to avoid overflow.
|
||||||
|
"""
|
||||||
|
if x > 500:
|
||||||
|
return 1.0
|
||||||
|
if x < -500:
|
||||||
|
return 0.0
|
||||||
|
return 1.0 / (1.0 + math.exp(-x))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shannon entropy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _shannon_entropy(p: float) -> float:
|
||||||
|
"""Compute Shannon entropy H = -p·log₂(p) - (1-p)·log₂(1-p).
|
||||||
|
|
||||||
|
Returns 0.0 at the boundaries (p = 0 or p = 1).
|
||||||
|
Result is in [0, 1] for binary entropy.
|
||||||
|
"""
|
||||||
|
if p <= 0.0 or p >= 1.0:
|
||||||
|
return 0.0
|
||||||
|
return -(p * math.log2(p) + (1.0 - p) * math.log2(1.0 - p))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EV_R computation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_ev_r(
|
||||||
|
p_up: float,
|
||||||
|
confluence_signals: list[ConfluenceSignal],
|
||||||
|
) -> float:
|
||||||
|
"""Compute expected value per unit risk.
|
||||||
|
|
||||||
|
EV_R = P_up · E[win_R] - (1 - P_up) · 1.0
|
||||||
|
|
||||||
|
E[win_R] is estimated as the average confluence_score × 2.0
|
||||||
|
(heuristic for expected win in R-units). Falls back to 1.0 if
|
||||||
|
no signals are available.
|
||||||
|
"""
|
||||||
|
if confluence_signals:
|
||||||
|
avg_score = sum(s.confluence_score for s in confluence_signals) / len(
|
||||||
|
confluence_signals
|
||||||
|
)
|
||||||
|
e_win_r = avg_score * 2.0
|
||||||
|
else:
|
||||||
|
e_win_r = 1.0
|
||||||
|
|
||||||
|
return p_up * e_win_r - (1.0 - p_up) * 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verdict logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_verdict(
|
||||||
|
p_up: float,
|
||||||
|
entropy: float,
|
||||||
|
ev_r: float,
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
config: ProbabilisticConfig,
|
||||||
|
) -> tuple[Verdict, list[str]]:
|
||||||
|
"""Apply threshold logic to determine BUY / WATCH / SKIP verdict.
|
||||||
|
|
||||||
|
Returns the verdict and a list of reasoning strings.
|
||||||
|
|
||||||
|
Requirements: 6.6, 6.7, 6.8
|
||||||
|
"""
|
||||||
|
reasoning: list[str] = []
|
||||||
|
|
||||||
|
valuation_score = (
|
||||||
|
normalized.valuation_score if normalized.valuation_score is not None else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Entropy gating (Req 6.4) ---
|
||||||
|
if entropy > config.entropy_skip:
|
||||||
|
reasoning.append(
|
||||||
|
f"SKIP: entropy={entropy:.4f} > {config.entropy_skip} (high_entropy)"
|
||||||
|
)
|
||||||
|
return Verdict.SKIP, reasoning
|
||||||
|
|
||||||
|
# --- Check BUY conditions (Req 6.6) ---
|
||||||
|
buy_conditions = {
|
||||||
|
"p_up": p_up >= config.buy_p_up,
|
||||||
|
"entropy": entropy <= config.buy_entropy_max,
|
||||||
|
"ev_r": ev_r >= config.buy_ev_r_min,
|
||||||
|
"macro_bias": normalized.macro_bias > config.macro_bias_threshold,
|
||||||
|
"valuation": valuation_score >= config.buy_valuation_min,
|
||||||
|
}
|
||||||
|
|
||||||
|
all_buy_met = all(buy_conditions.values())
|
||||||
|
|
||||||
|
if all_buy_met:
|
||||||
|
reasoning.append(
|
||||||
|
f"BUY: all conditions met — P_up={p_up:.4f} "
|
||||||
|
f"(>= {config.buy_p_up}), entropy={entropy:.4f} "
|
||||||
|
f"(<= {config.buy_entropy_max}), EV_R={ev_r:.4f} "
|
||||||
|
f"(>= {config.buy_ev_r_min}), macro_bias={normalized.macro_bias:.2f} "
|
||||||
|
f"(> {config.macro_bias_threshold}), valuation={valuation_score:.2f} "
|
||||||
|
f"(>= {config.buy_valuation_min})"
|
||||||
|
)
|
||||||
|
return Verdict.BUY, reasoning
|
||||||
|
|
||||||
|
# --- Check WATCH conditions (Req 6.7) ---
|
||||||
|
watch_conditions = {
|
||||||
|
"p_up": p_up >= config.watch_p_up,
|
||||||
|
"entropy": entropy <= config.watch_entropy_max,
|
||||||
|
}
|
||||||
|
|
||||||
|
if all(watch_conditions.values()):
|
||||||
|
failed_buy = [k for k, v in buy_conditions.items() if not v]
|
||||||
|
reasoning.append(
|
||||||
|
f"WATCH: P_up={p_up:.4f} (>= {config.watch_p_up}), "
|
||||||
|
f"entropy={entropy:.4f} (<= {config.watch_entropy_max}) "
|
||||||
|
f"but BUY conditions not fully met — failed: {', '.join(failed_buy)}"
|
||||||
|
)
|
||||||
|
return Verdict.WATCH, reasoning
|
||||||
|
|
||||||
|
# --- SKIP (Req 6.8) ---
|
||||||
|
reasoning.append(
|
||||||
|
f"SKIP: P_up={p_up:.4f}, entropy={entropy:.4f}, EV_R={ev_r:.4f} "
|
||||||
|
f"— does not meet WATCH or BUY thresholds"
|
||||||
|
)
|
||||||
|
return Verdict.SKIP, reasoning
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_probabilistic_pipeline(
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
confluence_signals: list[ConfluenceSignal],
|
||||||
|
regime: RegimeClassification,
|
||||||
|
config: ProbabilisticConfig,
|
||||||
|
) -> ProbabilisticResult:
|
||||||
|
"""Run the Bayesian probabilistic pipeline.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Initialize regime-based prior (bull=0.58, range=0.50, bear=0.42)
|
||||||
|
2. Compute likelihood ratios per signal
|
||||||
|
3. Apply correlation penalty via ``apply_correlation_penalty()``
|
||||||
|
4. Accumulate via log-odds: logit(P_post) = logit(P_prior) + Σ log(LR_i)
|
||||||
|
5. Compute Shannon entropy and apply entropy gating
|
||||||
|
6. Compute EV_R = P_up · E[win_R] - (1 - P_up) · 1.0
|
||||||
|
7. Produce BUY / WATCH / SKIP verdict
|
||||||
|
|
||||||
|
Args:
|
||||||
|
normalized: The unified input structure for this evaluation tick.
|
||||||
|
confluence_signals: Signals that passed multi-timeframe confluence
|
||||||
|
filtering.
|
||||||
|
regime: The current market regime classification.
|
||||||
|
config: Probabilistic pipeline thresholds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`ProbabilisticResult` with verdict, posterior, entropy,
|
||||||
|
EV_R, likelihood ratios, and reasoning.
|
||||||
|
|
||||||
|
Requirements: 6.1–6.9, 14.1–14.5
|
||||||
|
"""
|
||||||
|
reasoning: list[str] = []
|
||||||
|
|
||||||
|
# 1. Regime-based prior (Req 6.1, 14.2)
|
||||||
|
prior = _regime_to_prior(regime, config)
|
||||||
|
reasoning.append(
|
||||||
|
f"Regime={regime.regime.value}, trend_indicator={regime.trend_indicator:.1f} "
|
||||||
|
f"→ prior={prior:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Compute likelihood ratios (Req 6.2)
|
||||||
|
raw_lrs = _compute_likelihood_ratios(confluence_signals)
|
||||||
|
|
||||||
|
# 3. Apply correlation penalty (Req 7.1–7.4)
|
||||||
|
penalized_lrs = apply_correlation_penalty(raw_lrs)
|
||||||
|
|
||||||
|
# 4. Accumulate via log-odds (Req 6.3, 14.3)
|
||||||
|
logit_prior = _logit(prior)
|
||||||
|
sum_penalized_log_lr = sum(lr.penalized_log_lr for lr in penalized_lrs)
|
||||||
|
logit_posterior = logit_prior + sum_penalized_log_lr
|
||||||
|
p_up = _sigmoid(logit_posterior)
|
||||||
|
|
||||||
|
reasoning.append(
|
||||||
|
f"logit(prior)={logit_prior:.4f} + Σ penalized_log_lr={sum_penalized_log_lr:.4f} "
|
||||||
|
f"= logit(posterior)={logit_posterior:.4f} → P_up={p_up:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Shannon entropy (Req 6.4)
|
||||||
|
entropy = _shannon_entropy(p_up)
|
||||||
|
reasoning.append(f"Shannon entropy H={entropy:.4f}")
|
||||||
|
|
||||||
|
# 6. EV_R (Req 6.5)
|
||||||
|
ev_r = _compute_ev_r(p_up, confluence_signals)
|
||||||
|
reasoning.append(f"EV_R={ev_r:.4f}")
|
||||||
|
|
||||||
|
# 7. Verdict (Req 6.6, 6.7, 6.8)
|
||||||
|
verdict, verdict_reasoning = _determine_verdict(
|
||||||
|
p_up, entropy, ev_r, normalized, config
|
||||||
|
)
|
||||||
|
reasoning.extend(verdict_reasoning)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Probabilistic pipeline [%s]: verdict=%s P_up=%.4f "
|
||||||
|
"entropy=%.4f EV_R=%.4f prior=%.2f regime=%s signals=%d",
|
||||||
|
normalized.ticker,
|
||||||
|
verdict.value,
|
||||||
|
p_up,
|
||||||
|
entropy,
|
||||||
|
ev_r,
|
||||||
|
prior,
|
||||||
|
regime.regime.value,
|
||||||
|
len(confluence_signals),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProbabilisticResult(
|
||||||
|
verdict=verdict,
|
||||||
|
p_up=p_up,
|
||||||
|
entropy=entropy,
|
||||||
|
ev_r=ev_r,
|
||||||
|
prior=prior,
|
||||||
|
posterior=p_up,
|
||||||
|
likelihood_ratios=penalized_lrs,
|
||||||
|
regime=regime.regime.value,
|
||||||
|
reasoning=reasoning,
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Signal Library - technical signal evaluators (Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Base protocol and common helpers for signal evaluators.
|
||||||
|
|
||||||
|
Defines the ``SignalEvaluator`` protocol that every signal in the Signal
|
||||||
|
Library must satisfy, plus shared utility functions for swing detection,
|
||||||
|
lookback validation, and simple moving average computation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalResult
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal evaluator protocol
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SignalEvaluator(Protocol):
|
||||||
|
"""Protocol for all signal evaluators in the Signal Library.
|
||||||
|
|
||||||
|
Each evaluator receives a list of OHLCV bars for a single timeframe
|
||||||
|
and returns a ``SignalResult`` when the signal triggers, or ``None``
|
||||||
|
when insufficient data is available or the signal does not fire.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
timeframe: str,
|
||||||
|
) -> SignalResult | None:
|
||||||
|
"""Evaluate a signal on a single timeframe's bar data.
|
||||||
|
|
||||||
|
Returns ``None`` when insufficient data is available.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Common helper functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def find_swing_high(
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
lookback: int,
|
||||||
|
) -> tuple[int, float] | None:
|
||||||
|
"""Find the highest high in the last *lookback* bars.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bars: OHLCV bar series (oldest-first).
|
||||||
|
lookback: Number of recent bars to search.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(index, price)`` of the bar with the highest high within the
|
||||||
|
lookback window, or ``None`` if *bars* has fewer than *lookback*
|
||||||
|
entries.
|
||||||
|
"""
|
||||||
|
if len(bars) < lookback or lookback <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
window = bars[-lookback:]
|
||||||
|
offset = len(bars) - lookback
|
||||||
|
|
||||||
|
best_idx = 0
|
||||||
|
best_price = window[0].high
|
||||||
|
for i, bar in enumerate(window):
|
||||||
|
if bar.high >= best_price:
|
||||||
|
best_idx = i
|
||||||
|
best_price = bar.high
|
||||||
|
|
||||||
|
return (offset + best_idx, best_price)
|
||||||
|
|
||||||
|
|
||||||
|
def find_swing_low(
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
lookback: int,
|
||||||
|
) -> tuple[int, float] | None:
|
||||||
|
"""Find the lowest low in the last *lookback* bars.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bars: OHLCV bar series (oldest-first).
|
||||||
|
lookback: Number of recent bars to search.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(index, price)`` of the bar with the lowest low within the
|
||||||
|
lookback window, or ``None`` if *bars* has fewer than *lookback*
|
||||||
|
entries.
|
||||||
|
"""
|
||||||
|
if len(bars) < lookback or lookback <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
window = bars[-lookback:]
|
||||||
|
offset = len(bars) - lookback
|
||||||
|
|
||||||
|
best_idx = 0
|
||||||
|
best_price = window[0].low
|
||||||
|
for i, bar in enumerate(window):
|
||||||
|
if bar.low <= best_price:
|
||||||
|
best_idx = i
|
||||||
|
best_price = bar.low
|
||||||
|
|
||||||
|
return (offset + best_idx, best_price)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_lookback(bars: list[OHLCVBar], min_bars: int) -> bool:
|
||||||
|
"""Return ``True`` if *bars* contains at least *min_bars* entries."""
|
||||||
|
return len(bars) >= min_bars
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sma(bars: list[OHLCVBar], period: int) -> float | None:
|
||||||
|
"""Compute the simple moving average of close prices over the last *period* bars.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bars: OHLCV bar series (oldest-first).
|
||||||
|
period: Number of recent bars to average.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The arithmetic mean of the last *period* close prices, or ``None``
|
||||||
|
if *bars* has fewer than *period* entries or *period* is not
|
||||||
|
positive.
|
||||||
|
"""
|
||||||
|
if period <= 0 or len(bars) < period:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total = sum(bar.close for bar in bars[-period:])
|
||||||
|
return total / period
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Cup & Handle pattern signal evaluator.
|
||||||
|
|
||||||
|
Detects the Cup & Handle chart pattern — a bullish continuation pattern
|
||||||
|
consisting of a U-shaped price recovery (the cup) followed by a small
|
||||||
|
consolidation pullback (the handle).
|
||||||
|
|
||||||
|
Pattern detection algorithm:
|
||||||
|
1. Find the left rim (local high in the first third of bars).
|
||||||
|
2. Find the cup bottom (lowest low between left rim and right rim area).
|
||||||
|
3. Find the right rim (local high in the last third of bars, near left rim price).
|
||||||
|
4. Identify the handle as a small pullback after the right rim (last few bars).
|
||||||
|
|
||||||
|
Pattern completeness scoring:
|
||||||
|
- Cup depth: ``(left_rim - bottom) / left_rim`` — valid range 12–33%.
|
||||||
|
- Symmetry: how close left_rim and right_rim prices are (within 5% = perfect).
|
||||||
|
- Handle: small pullback (< 50% of cup depth) after right rim.
|
||||||
|
|
||||||
|
The signal is always BULLISH (cup & handle is a bullish continuation pattern).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||||
|
from services.signal_engine.signals.base import validate_lookback
|
||||||
|
|
||||||
|
# Default minimum number of bars required for cup & handle detection
|
||||||
|
DEFAULT_MIN_BARS: int = 30
|
||||||
|
|
||||||
|
# Cup depth valid range (as fraction of left rim price)
|
||||||
|
_CUP_DEPTH_MIN: float = 0.12 # 12%
|
||||||
|
_CUP_DEPTH_MAX: float = 0.33 # 33%
|
||||||
|
|
||||||
|
# Symmetry: maximum allowed difference between left and right rim prices
|
||||||
|
# as a fraction of left rim price for "perfect" symmetry
|
||||||
|
_SYMMETRY_PERFECT_PCT: float = 0.05 # 5%
|
||||||
|
|
||||||
|
# Handle: maximum pullback as fraction of cup depth
|
||||||
|
_HANDLE_MAX_RETRACE: float = 0.50 # 50% of cup depth
|
||||||
|
|
||||||
|
# Handle lookback: number of bars at the end to check for handle
|
||||||
|
_HANDLE_LOOKBACK_FRACTION: float = 0.15 # last 15% of bars
|
||||||
|
|
||||||
|
# Confidence multiplier
|
||||||
|
_CONFIDENCE_MULTIPLIER: float = 0.90
|
||||||
|
|
||||||
|
|
||||||
|
class CupHandleEvaluator:
|
||||||
|
"""Cup & Handle pattern signal evaluator.
|
||||||
|
|
||||||
|
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||||
|
protocol.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
min_bars:
|
||||||
|
Minimum number of OHLCV bars required before the evaluator will
|
||||||
|
produce a signal. Defaults to ``30``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, min_bars: int = DEFAULT_MIN_BARS) -> None:
|
||||||
|
self.min_bars = min_bars
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API (SignalEvaluator protocol)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
timeframe: str,
|
||||||
|
) -> SignalResult | None:
|
||||||
|
"""Evaluate Cup & Handle pattern on *bars* for *timeframe*.
|
||||||
|
|
||||||
|
Returns ``None`` when there are fewer than :pyattr:`min_bars` bars,
|
||||||
|
or when no valid cup & handle pattern is detected.
|
||||||
|
"""
|
||||||
|
if not validate_lookback(bars, self.min_bars):
|
||||||
|
return None
|
||||||
|
|
||||||
|
n = len(bars)
|
||||||
|
|
||||||
|
# --- Step 1: Find the left rim (highest high in first third) ---
|
||||||
|
first_third_end = n // 3
|
||||||
|
if first_third_end < 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
left_rim_idx = 0
|
||||||
|
left_rim_price = bars[0].high
|
||||||
|
for i in range(1, first_third_end):
|
||||||
|
if bars[i].high > left_rim_price:
|
||||||
|
left_rim_idx = i
|
||||||
|
left_rim_price = bars[i].high
|
||||||
|
|
||||||
|
if left_rim_price <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Step 2: Find the right rim (highest high in last third) ---
|
||||||
|
last_third_start = n - (n // 3)
|
||||||
|
if last_third_start >= n:
|
||||||
|
return None
|
||||||
|
|
||||||
|
right_rim_idx = last_third_start
|
||||||
|
right_rim_price = bars[last_third_start].high
|
||||||
|
for i in range(last_third_start + 1, n):
|
||||||
|
if bars[i].high > right_rim_price:
|
||||||
|
right_rim_idx = i
|
||||||
|
right_rim_price = bars[i].high
|
||||||
|
|
||||||
|
# --- Step 3: Find the cup bottom (lowest low between rims) ---
|
||||||
|
search_start = left_rim_idx + 1
|
||||||
|
search_end = right_rim_idx
|
||||||
|
if search_start >= search_end:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bottom_idx = search_start
|
||||||
|
bottom_price = bars[search_start].low
|
||||||
|
for i in range(search_start + 1, search_end):
|
||||||
|
if bars[i].low < bottom_price:
|
||||||
|
bottom_idx = i
|
||||||
|
bottom_price = bars[i].low
|
||||||
|
|
||||||
|
# --- Step 4: Validate cup depth ---
|
||||||
|
cup_depth = left_rim_price - bottom_price
|
||||||
|
if cup_depth <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cup_depth_pct = cup_depth / left_rim_price
|
||||||
|
if cup_depth_pct < _CUP_DEPTH_MIN or cup_depth_pct > _CUP_DEPTH_MAX:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Step 5: Score symmetry (left rim vs right rim) ---
|
||||||
|
rim_diff_pct = abs(left_rim_price - right_rim_price) / left_rim_price
|
||||||
|
if rim_diff_pct <= _SYMMETRY_PERFECT_PCT:
|
||||||
|
symmetry_score = 1.0
|
||||||
|
else:
|
||||||
|
# Linear decay from 1.0 at 5% to 0.0 at 20%
|
||||||
|
max_diff = 0.20
|
||||||
|
symmetry_score = max(0.0, 1.0 - (rim_diff_pct - _SYMMETRY_PERFECT_PCT) / (max_diff - _SYMMETRY_PERFECT_PCT))
|
||||||
|
|
||||||
|
# Right rim must be at least close to left rim (within 20%)
|
||||||
|
if symmetry_score <= 0.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Step 6: Detect and score the handle ---
|
||||||
|
handle_lookback = max(2, int(n * _HANDLE_LOOKBACK_FRACTION))
|
||||||
|
handle_bars = bars[-handle_lookback:]
|
||||||
|
|
||||||
|
# Handle is a small pullback from the right rim
|
||||||
|
handle_low = min(b.low for b in handle_bars)
|
||||||
|
handle_depth = right_rim_price - handle_low
|
||||||
|
|
||||||
|
if cup_depth <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
handle_retrace = handle_depth / cup_depth
|
||||||
|
|
||||||
|
if handle_retrace > _HANDLE_MAX_RETRACE:
|
||||||
|
# Handle is too deep — not a valid cup & handle
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle score: 1.0 when handle is very shallow, decreasing as it deepens
|
||||||
|
if handle_retrace <= 0:
|
||||||
|
handle_score = 1.0
|
||||||
|
else:
|
||||||
|
handle_score = 1.0 - (handle_retrace / _HANDLE_MAX_RETRACE)
|
||||||
|
|
||||||
|
# --- Step 7: Cup depth quality score ---
|
||||||
|
# Ideal cup depth is around 20-25% — score peaks in the middle of valid range
|
||||||
|
ideal_depth = (_CUP_DEPTH_MIN + _CUP_DEPTH_MAX) / 2.0 # 0.225
|
||||||
|
depth_deviation = abs(cup_depth_pct - ideal_depth) / ((_CUP_DEPTH_MAX - _CUP_DEPTH_MIN) / 2.0)
|
||||||
|
depth_score = max(0.0, 1.0 - depth_deviation)
|
||||||
|
|
||||||
|
# --- Step 8: Compute overall completeness ---
|
||||||
|
completeness = (
|
||||||
|
0.35 * symmetry_score
|
||||||
|
+ 0.35 * depth_score
|
||||||
|
+ 0.30 * handle_score
|
||||||
|
)
|
||||||
|
completeness = max(0.0, min(1.0, completeness))
|
||||||
|
|
||||||
|
# --- Step 9: Build signal result ---
|
||||||
|
strength = completeness
|
||||||
|
confidence = completeness * _CONFIDENCE_MULTIPLIER
|
||||||
|
|
||||||
|
return SignalResult(
|
||||||
|
signal_type="cup_handle",
|
||||||
|
timeframe=timeframe,
|
||||||
|
strength=strength,
|
||||||
|
direction=SignalDirection.BULLISH,
|
||||||
|
confidence=confidence,
|
||||||
|
metadata={
|
||||||
|
"left_rim": left_rim_price,
|
||||||
|
"left_rim_idx": left_rim_idx,
|
||||||
|
"right_rim": right_rim_price,
|
||||||
|
"right_rim_idx": right_rim_idx,
|
||||||
|
"bottom": bottom_price,
|
||||||
|
"bottom_idx": bottom_idx,
|
||||||
|
"cup_depth_pct": round(cup_depth_pct, 4),
|
||||||
|
"handle_depth": round(handle_depth, 4),
|
||||||
|
"handle_retrace_pct": round(handle_retrace, 4),
|
||||||
|
"symmetry_score": round(symmetry_score, 4),
|
||||||
|
"depth_score": round(depth_score, 4),
|
||||||
|
"handle_score": round(handle_score, 4),
|
||||||
|
"completeness": round(completeness, 4),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
"""Elliott Wave signal evaluator.
|
||||||
|
|
||||||
|
Detects Elliott Wave patterns — impulse waves (5-wave structure) and
|
||||||
|
corrective waves (3-wave structure) — using a simplified zigzag pivot
|
||||||
|
filter. Produces a signal with the current wave position and projected
|
||||||
|
direction.
|
||||||
|
|
||||||
|
Wave detection algorithm (simplified):
|
||||||
|
1. Find significant pivot points (local highs and lows) using a zigzag
|
||||||
|
filter that identifies reversals of at least X% of the price range.
|
||||||
|
2. Count alternating pivots to identify wave structure.
|
||||||
|
3. Five alternating pivots = impulse wave (bullish if trending up,
|
||||||
|
bearish if trending down).
|
||||||
|
4. Three alternating pivots after an impulse = corrective wave.
|
||||||
|
|
||||||
|
Signal logic:
|
||||||
|
- Impulse wave 3 or 5: strong signal in the trend direction.
|
||||||
|
- Corrective wave (A, B, C): signal in the opposite direction
|
||||||
|
(anticipating next impulse).
|
||||||
|
- Ambiguous wave count: return ``None``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||||
|
from services.signal_engine.signals.base import validate_lookback
|
||||||
|
|
||||||
|
# Default minimum number of bars required for evaluation
|
||||||
|
DEFAULT_MIN_BARS: int = 30
|
||||||
|
|
||||||
|
# Minimum zigzag reversal threshold as a fraction of the price range
|
||||||
|
_DEFAULT_ZIGZAG_PCT: float = 0.05 # 5%
|
||||||
|
|
||||||
|
# Wave type labels
|
||||||
|
WAVE_TYPE_IMPULSE: str = "impulse"
|
||||||
|
WAVE_TYPE_CORRECTIVE: str = "corrective"
|
||||||
|
|
||||||
|
# Impulse wave positions (1-indexed)
|
||||||
|
_IMPULSE_WAVE_COUNT: int = 5
|
||||||
|
# Corrective wave positions
|
||||||
|
_CORRECTIVE_WAVE_COUNT: int = 3
|
||||||
|
|
||||||
|
# Confidence multiplier for wave clarity
|
||||||
|
_CONFIDENCE_MULTIPLIER: float = 0.85
|
||||||
|
|
||||||
|
|
||||||
|
class ElliottWaveEvaluator:
|
||||||
|
"""Elliott Wave pattern signal evaluator.
|
||||||
|
|
||||||
|
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||||
|
protocol.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
min_bars:
|
||||||
|
Minimum number of OHLCV bars required before the evaluator will
|
||||||
|
produce a signal. Defaults to ``30``.
|
||||||
|
zigzag_pct:
|
||||||
|
Minimum reversal threshold as a fraction of the overall price
|
||||||
|
range for the zigzag filter. Defaults to ``0.05`` (5%).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
min_bars: int = DEFAULT_MIN_BARS,
|
||||||
|
zigzag_pct: float = _DEFAULT_ZIGZAG_PCT,
|
||||||
|
) -> None:
|
||||||
|
self.min_bars = min_bars
|
||||||
|
self.zigzag_pct = zigzag_pct
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API (SignalEvaluator protocol)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
timeframe: str,
|
||||||
|
) -> SignalResult | None:
|
||||||
|
"""Evaluate Elliott Wave pattern on *bars* for *timeframe*.
|
||||||
|
|
||||||
|
Returns ``None`` when there are fewer than :pyattr:`min_bars` bars,
|
||||||
|
when the market is flat (no price range), or when the wave count
|
||||||
|
is ambiguous.
|
||||||
|
"""
|
||||||
|
if not validate_lookback(bars, self.min_bars):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Compute overall price range for the zigzag threshold
|
||||||
|
overall_high = max(b.high for b in bars)
|
||||||
|
overall_low = min(b.low for b in bars)
|
||||||
|
price_range = overall_high - overall_low
|
||||||
|
|
||||||
|
if price_range <= 0:
|
||||||
|
return None # flat market
|
||||||
|
|
||||||
|
zigzag_threshold = price_range * self.zigzag_pct
|
||||||
|
|
||||||
|
# Find zigzag pivots
|
||||||
|
pivots = _find_zigzag_pivots(bars, zigzag_threshold)
|
||||||
|
|
||||||
|
if len(pivots) < _CORRECTIVE_WAVE_COUNT:
|
||||||
|
return None # not enough pivots for any wave structure
|
||||||
|
|
||||||
|
# Try to identify wave structure from the pivots
|
||||||
|
wave_info = _classify_waves(pivots, price_range)
|
||||||
|
|
||||||
|
if wave_info is None:
|
||||||
|
return None # ambiguous wave count
|
||||||
|
|
||||||
|
wave_type = wave_info["wave_type"]
|
||||||
|
current_position = wave_info["current_position"]
|
||||||
|
trend_up = wave_info["trend_up"]
|
||||||
|
clarity = wave_info["clarity"]
|
||||||
|
|
||||||
|
# Determine direction and strength based on wave type and position
|
||||||
|
direction: SignalDirection
|
||||||
|
strength: float
|
||||||
|
|
||||||
|
if wave_type == WAVE_TYPE_IMPULSE:
|
||||||
|
# Impulse wave: signal in the trend direction
|
||||||
|
direction = SignalDirection.BULLISH if trend_up else SignalDirection.BEARISH
|
||||||
|
# Waves 3 and 5 are the strongest signal points
|
||||||
|
if current_position in (3, 5):
|
||||||
|
strength = min(1.0, clarity * 1.0)
|
||||||
|
else:
|
||||||
|
strength = min(1.0, clarity * 0.6)
|
||||||
|
else:
|
||||||
|
# Corrective wave: signal opposite to the correction
|
||||||
|
# (anticipating next impulse in the original trend direction)
|
||||||
|
direction = SignalDirection.BULLISH if trend_up else SignalDirection.BEARISH
|
||||||
|
strength = min(1.0, clarity * 0.7)
|
||||||
|
|
||||||
|
confidence = min(1.0, clarity * _CONFIDENCE_MULTIPLIER)
|
||||||
|
|
||||||
|
# Build pivot list for metadata (index, price, type)
|
||||||
|
pivot_meta = [
|
||||||
|
{"index": p["index"], "price": p["price"], "type": p["type"]}
|
||||||
|
for p in pivots
|
||||||
|
]
|
||||||
|
|
||||||
|
return SignalResult(
|
||||||
|
signal_type="elliott_wave",
|
||||||
|
timeframe=timeframe,
|
||||||
|
strength=strength,
|
||||||
|
direction=direction,
|
||||||
|
confidence=confidence,
|
||||||
|
metadata={
|
||||||
|
"wave_count": len(pivots),
|
||||||
|
"wave_type": wave_type,
|
||||||
|
"current_wave_position": current_position,
|
||||||
|
"trend_up": trend_up,
|
||||||
|
"clarity": round(clarity, 4),
|
||||||
|
"pivots": pivot_meta,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _find_zigzag_pivots(
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
threshold: float,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Find significant pivot points using a zigzag filter.
|
||||||
|
|
||||||
|
A pivot is a local high or low where the price reverses by at least
|
||||||
|
*threshold* from the last confirmed pivot.
|
||||||
|
|
||||||
|
Returns a list of dicts with keys: ``index``, ``price``, ``type``
|
||||||
|
(``"high"`` or ``"low"``).
|
||||||
|
"""
|
||||||
|
if len(bars) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
pivots: list[dict] = []
|
||||||
|
|
||||||
|
# Seed with the first bar's high and low as candidates
|
||||||
|
last_high_idx = 0
|
||||||
|
last_high = bars[0].high
|
||||||
|
last_low_idx = 0
|
||||||
|
last_low = bars[0].low
|
||||||
|
|
||||||
|
# Direction: 1 = looking for a high (trending up), -1 = looking for a low
|
||||||
|
# Start by determining initial direction from first two bars
|
||||||
|
if bars[1].close >= bars[0].close:
|
||||||
|
direction = 1 # trending up, looking for a high
|
||||||
|
else:
|
||||||
|
direction = -1 # trending down, looking for a low
|
||||||
|
|
||||||
|
for i in range(1, len(bars)):
|
||||||
|
bar = bars[i]
|
||||||
|
|
||||||
|
if direction == 1:
|
||||||
|
# Trending up — track the highest high
|
||||||
|
if bar.high >= last_high:
|
||||||
|
last_high = bar.high
|
||||||
|
last_high_idx = i
|
||||||
|
# Check for reversal: price dropped by threshold from the high
|
||||||
|
if last_high - bar.low >= threshold:
|
||||||
|
# Confirm the high as a pivot
|
||||||
|
pivots.append({
|
||||||
|
"index": last_high_idx,
|
||||||
|
"price": last_high,
|
||||||
|
"type": "high",
|
||||||
|
})
|
||||||
|
# Switch direction: now looking for a low
|
||||||
|
direction = -1
|
||||||
|
last_low = bar.low
|
||||||
|
last_low_idx = i
|
||||||
|
else:
|
||||||
|
# Trending down — track the lowest low
|
||||||
|
if bar.low <= last_low:
|
||||||
|
last_low = bar.low
|
||||||
|
last_low_idx = i
|
||||||
|
# Check for reversal: price rose by threshold from the low
|
||||||
|
if bar.high - last_low >= threshold:
|
||||||
|
# Confirm the low as a pivot
|
||||||
|
pivots.append({
|
||||||
|
"index": last_low_idx,
|
||||||
|
"price": last_low,
|
||||||
|
"type": "low",
|
||||||
|
})
|
||||||
|
# Switch direction: now looking for a high
|
||||||
|
direction = 1
|
||||||
|
last_high = bar.high
|
||||||
|
last_high_idx = i
|
||||||
|
|
||||||
|
# Add the final unconfirmed pivot (the current trend endpoint)
|
||||||
|
if direction == 1 and (not pivots or pivots[-1]["type"] != "high"):
|
||||||
|
pivots.append({
|
||||||
|
"index": last_high_idx,
|
||||||
|
"price": last_high,
|
||||||
|
"type": "high",
|
||||||
|
})
|
||||||
|
elif direction == -1 and (not pivots or pivots[-1]["type"] != "low"):
|
||||||
|
pivots.append({
|
||||||
|
"index": last_low_idx,
|
||||||
|
"price": last_low,
|
||||||
|
"type": "low",
|
||||||
|
})
|
||||||
|
|
||||||
|
return pivots
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_waves(
|
||||||
|
pivots: list[dict],
|
||||||
|
price_range: float,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Classify the pivot sequence as impulse or corrective waves.
|
||||||
|
|
||||||
|
Returns a dict with ``wave_type``, ``current_position``, ``trend_up``,
|
||||||
|
and ``clarity``, or ``None`` if the wave count is ambiguous.
|
||||||
|
"""
|
||||||
|
n = len(pivots)
|
||||||
|
|
||||||
|
if n < _CORRECTIVE_WAVE_COUNT:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Determine overall trend from first to last pivot
|
||||||
|
first_price = pivots[0]["price"]
|
||||||
|
last_price = pivots[-1]["price"]
|
||||||
|
trend_up = last_price > first_price
|
||||||
|
|
||||||
|
# Try impulse wave (5 pivots) first, then corrective (3 pivots)
|
||||||
|
if n >= _IMPULSE_WAVE_COUNT:
|
||||||
|
# Use the last 5 pivots for impulse wave detection
|
||||||
|
impulse_pivots = pivots[-_IMPULSE_WAVE_COUNT:]
|
||||||
|
impulse_result = _check_impulse(impulse_pivots, trend_up, price_range)
|
||||||
|
if impulse_result is not None:
|
||||||
|
return impulse_result
|
||||||
|
|
||||||
|
# Check if there's a corrective wave after an impulse
|
||||||
|
# (need at least 5 + 3 = 8 pivots for impulse + corrective)
|
||||||
|
if n >= _IMPULSE_WAVE_COUNT + _CORRECTIVE_WAVE_COUNT:
|
||||||
|
# Check if the first 5 pivots form an impulse
|
||||||
|
early_impulse = pivots[:_IMPULSE_WAVE_COUNT]
|
||||||
|
early_result = _check_impulse(early_impulse, trend_up, price_range)
|
||||||
|
if early_result is not None:
|
||||||
|
# The remaining pivots may form a corrective wave
|
||||||
|
corrective_pivots = pivots[_IMPULSE_WAVE_COUNT:_IMPULSE_WAVE_COUNT + _CORRECTIVE_WAVE_COUNT]
|
||||||
|
corrective_result = _check_corrective(
|
||||||
|
corrective_pivots, trend_up, price_range,
|
||||||
|
)
|
||||||
|
if corrective_result is not None:
|
||||||
|
return corrective_result
|
||||||
|
|
||||||
|
# Try corrective wave (3 pivots) from the tail
|
||||||
|
if n >= _CORRECTIVE_WAVE_COUNT:
|
||||||
|
corrective_pivots = pivots[-_CORRECTIVE_WAVE_COUNT:]
|
||||||
|
corrective_result = _check_corrective(
|
||||||
|
corrective_pivots, trend_up, price_range,
|
||||||
|
)
|
||||||
|
if corrective_result is not None:
|
||||||
|
return corrective_result
|
||||||
|
|
||||||
|
return None # ambiguous
|
||||||
|
|
||||||
|
|
||||||
|
def _check_impulse(
|
||||||
|
pivots: list[dict],
|
||||||
|
trend_up: bool,
|
||||||
|
price_range: float,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Check if 5 pivots form a valid impulse wave.
|
||||||
|
|
||||||
|
For a bullish impulse (trend_up=True):
|
||||||
|
- Wave 1 (low→high): price rises
|
||||||
|
- Wave 2 (high→low): price falls but stays above wave 1 start
|
||||||
|
- Wave 3 (low→high): price rises above wave 1 high (wave 3 is longest)
|
||||||
|
- Wave 4 (high→low): price falls but stays above wave 1 high
|
||||||
|
- Wave 5 (low→high): price rises to new high
|
||||||
|
|
||||||
|
For bearish impulse, the pattern is inverted.
|
||||||
|
"""
|
||||||
|
if len(pivots) != _IMPULSE_WAVE_COUNT:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prices = [p["price"] for p in pivots]
|
||||||
|
|
||||||
|
if trend_up:
|
||||||
|
# Bullish impulse: alternating low-high-low-high-low or high-low-high-low-high
|
||||||
|
# Check for generally ascending pattern with higher highs
|
||||||
|
valid = _validate_bullish_impulse(prices)
|
||||||
|
else:
|
||||||
|
# Bearish impulse: generally descending pattern with lower lows
|
||||||
|
valid = _validate_bearish_impulse(prices)
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Compute clarity: how clean the wave structure is
|
||||||
|
clarity = _compute_impulse_clarity(prices, trend_up, price_range)
|
||||||
|
|
||||||
|
# Current position is wave 5 (the last wave in the impulse)
|
||||||
|
return {
|
||||||
|
"wave_type": WAVE_TYPE_IMPULSE,
|
||||||
|
"current_position": 5,
|
||||||
|
"trend_up": trend_up,
|
||||||
|
"clarity": clarity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bullish_impulse(prices: list[float]) -> bool:
|
||||||
|
"""Validate a 5-pivot sequence as a bullish impulse.
|
||||||
|
|
||||||
|
Simplified rules:
|
||||||
|
- The overall trend is up (last > first).
|
||||||
|
- Wave 3 (pivot 2 to pivot 3) should be the largest move or
|
||||||
|
at least not the shortest.
|
||||||
|
- Wave 2 should not retrace below wave 1 start.
|
||||||
|
- Wave 4 should not overlap wave 1 end.
|
||||||
|
"""
|
||||||
|
if len(prices) != 5:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Overall upward trend
|
||||||
|
if prices[-1] <= prices[0]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Compute wave magnitudes
|
||||||
|
waves = [abs(prices[i + 1] - prices[i]) for i in range(4)]
|
||||||
|
|
||||||
|
# Wave 3 (index 2) should not be the shortest impulse wave
|
||||||
|
# Impulse waves are waves 0, 2, 4 (odd-indexed moves in 0-based)
|
||||||
|
impulse_waves = [waves[0], waves[2]]
|
||||||
|
if len(waves) > 3:
|
||||||
|
impulse_waves.append(waves[3])
|
||||||
|
|
||||||
|
# Wave 3 (waves[2]) should be significant
|
||||||
|
if waves[2] < min(waves[0], waves[2]) * 0.5:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# The pattern should show alternating direction
|
||||||
|
# Check that consecutive pivots alternate in direction
|
||||||
|
for i in range(3):
|
||||||
|
move_a = prices[i + 1] - prices[i]
|
||||||
|
move_b = prices[i + 2] - prices[i + 1]
|
||||||
|
# Consecutive moves should be in opposite directions
|
||||||
|
if move_a * move_b >= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bearish_impulse(prices: list[float]) -> bool:
|
||||||
|
"""Validate a 5-pivot sequence as a bearish impulse.
|
||||||
|
|
||||||
|
Mirror of bullish validation with inverted price direction.
|
||||||
|
"""
|
||||||
|
if len(prices) != 5:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Overall downward trend
|
||||||
|
if prices[-1] >= prices[0]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Compute wave magnitudes
|
||||||
|
waves = [abs(prices[i + 1] - prices[i]) for i in range(4)]
|
||||||
|
|
||||||
|
# Wave 3 (waves[2]) should be significant
|
||||||
|
if waves[2] < min(waves[0], waves[2]) * 0.5:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check alternating direction
|
||||||
|
for i in range(3):
|
||||||
|
move_a = prices[i + 1] - prices[i]
|
||||||
|
move_b = prices[i + 2] - prices[i + 1]
|
||||||
|
if move_a * move_b >= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_impulse_clarity(
|
||||||
|
prices: list[float],
|
||||||
|
trend_up: bool,
|
||||||
|
price_range: float,
|
||||||
|
) -> float:
|
||||||
|
"""Compute wave clarity for an impulse wave.
|
||||||
|
|
||||||
|
Clarity is based on:
|
||||||
|
- How well the pivots alternate (already validated).
|
||||||
|
- How proportional the wave magnitudes are.
|
||||||
|
- How significant the waves are relative to the price range.
|
||||||
|
"""
|
||||||
|
if price_range <= 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
waves = [abs(prices[i + 1] - prices[i]) for i in range(4)]
|
||||||
|
total_movement = sum(waves)
|
||||||
|
|
||||||
|
# Significance: total wave movement relative to price range
|
||||||
|
significance = min(1.0, total_movement / (price_range * 2.0))
|
||||||
|
|
||||||
|
# Proportionality: wave 3 should be the largest or close to it
|
||||||
|
max_wave = max(waves)
|
||||||
|
if max_wave <= 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
wave3_ratio = waves[2] / max_wave # 1.0 if wave 3 is the largest
|
||||||
|
|
||||||
|
# Overall clarity
|
||||||
|
clarity = 0.5 * significance + 0.5 * wave3_ratio
|
||||||
|
return max(0.0, min(1.0, clarity))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_corrective(
|
||||||
|
pivots: list[dict],
|
||||||
|
trend_up: bool,
|
||||||
|
price_range: float,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Check if 3 pivots form a valid corrective wave (A-B-C).
|
||||||
|
|
||||||
|
A corrective wave moves against the main trend:
|
||||||
|
- For a bullish main trend: corrective wave moves down (A down, B up, C down).
|
||||||
|
- For a bearish main trend: corrective wave moves up (A up, B down, C up).
|
||||||
|
"""
|
||||||
|
if len(pivots) != _CORRECTIVE_WAVE_COUNT:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prices = [p["price"] for p in pivots]
|
||||||
|
|
||||||
|
# Check alternating direction
|
||||||
|
move_a = prices[1] - prices[0]
|
||||||
|
move_b = prices[2] - prices[1]
|
||||||
|
|
||||||
|
# Moves must be in opposite directions
|
||||||
|
if move_a * move_b >= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# For a bullish main trend, the corrective wave should move down overall
|
||||||
|
if trend_up:
|
||||||
|
if prices[2] >= prices[0]:
|
||||||
|
return None # not a downward correction
|
||||||
|
else:
|
||||||
|
if prices[2] <= prices[0]:
|
||||||
|
return None # not an upward correction
|
||||||
|
|
||||||
|
# Compute clarity
|
||||||
|
waves = [abs(prices[1] - prices[0]), abs(prices[2] - prices[1])]
|
||||||
|
total_movement = sum(waves)
|
||||||
|
|
||||||
|
if price_range <= 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
significance = min(1.0, total_movement / price_range)
|
||||||
|
clarity = significance * 0.8 # corrective waves are inherently less clear
|
||||||
|
|
||||||
|
# Current position is wave C (the last wave in the correction)
|
||||||
|
return {
|
||||||
|
"wave_type": WAVE_TYPE_CORRECTIVE,
|
||||||
|
"current_position": 3, # wave C
|
||||||
|
"trend_up": trend_up,
|
||||||
|
"clarity": max(0.0, min(1.0, clarity)),
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Fibonacci retracement signal evaluator.
|
||||||
|
|
||||||
|
Computes retracement levels using ``L(r) = SH - r * (SH - SL)`` for the
|
||||||
|
standard ratios [0.236, 0.382, 0.5, 0.618, 0.786] and produces a signal
|
||||||
|
based on the proximity of the current price to the nearest level.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||||
|
from services.signal_engine.signals.base import (
|
||||||
|
find_swing_high,
|
||||||
|
find_swing_low,
|
||||||
|
validate_lookback,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Standard Fibonacci retracement ratios
|
||||||
|
RETRACEMENT_RATIOS: list[float] = [0.236, 0.382, 0.5, 0.618, 0.786]
|
||||||
|
|
||||||
|
# Ratios considered "key" levels — proximity to these yields higher confidence
|
||||||
|
_KEY_RATIOS: set[float] = {0.5, 0.618}
|
||||||
|
|
||||||
|
# Default minimum number of bars required for evaluation
|
||||||
|
DEFAULT_MIN_BARS: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class FibonacciEvaluator:
|
||||||
|
"""Fibonacci retracement signal evaluator.
|
||||||
|
|
||||||
|
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||||
|
protocol.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
min_bars:
|
||||||
|
Minimum number of OHLCV bars required before the evaluator will
|
||||||
|
produce a signal. Defaults to ``20``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, min_bars: int = DEFAULT_MIN_BARS) -> None:
|
||||||
|
self.min_bars = min_bars
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API (SignalEvaluator protocol)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
timeframe: str,
|
||||||
|
) -> SignalResult | None:
|
||||||
|
"""Evaluate Fibonacci retracement on *bars* for *timeframe*.
|
||||||
|
|
||||||
|
Returns ``None`` when there are fewer than :pyattr:`min_bars` bars,
|
||||||
|
or when the swing high equals the swing low (flat market — no valid
|
||||||
|
retracement).
|
||||||
|
"""
|
||||||
|
if not validate_lookback(bars, self.min_bars):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Detect swing high / swing low within the evaluation window
|
||||||
|
sh_result = find_swing_high(bars, self.min_bars)
|
||||||
|
sl_result = find_swing_low(bars, self.min_bars)
|
||||||
|
|
||||||
|
if sh_result is None or sl_result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_sh_idx, sh_price = sh_result
|
||||||
|
_sl_idx, sl_price = sl_result
|
||||||
|
|
||||||
|
# SH must be strictly greater than SL for a valid retracement range
|
||||||
|
if sh_price <= sl_price:
|
||||||
|
return None
|
||||||
|
|
||||||
|
price_range = sh_price - sl_price
|
||||||
|
current_price = bars[-1].close
|
||||||
|
|
||||||
|
# Compute retracement levels: L(r) = SH - r * (SH - SL)
|
||||||
|
levels: dict[float, float] = {
|
||||||
|
r: sh_price - r * price_range for r in RETRACEMENT_RATIOS
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the nearest retracement level to the current price
|
||||||
|
nearest_ratio: float = RETRACEMENT_RATIOS[0]
|
||||||
|
nearest_level: float = levels[nearest_ratio]
|
||||||
|
min_distance: float = abs(current_price - nearest_level)
|
||||||
|
|
||||||
|
for ratio in RETRACEMENT_RATIOS[1:]:
|
||||||
|
distance = abs(current_price - levels[ratio])
|
||||||
|
if distance < min_distance:
|
||||||
|
min_distance = distance
|
||||||
|
nearest_ratio = ratio
|
||||||
|
nearest_level = levels[ratio]
|
||||||
|
|
||||||
|
# Signal strength: 1.0 - (distance / range), clamped to [0, 1]
|
||||||
|
raw_strength = 1.0 - (min_distance / price_range)
|
||||||
|
strength = max(0.0, min(1.0, raw_strength))
|
||||||
|
|
||||||
|
# Direction: BULLISH if price is near a retracement level and above SL
|
||||||
|
# (potential bounce off support). Otherwise BEARISH.
|
||||||
|
if current_price >= sl_price:
|
||||||
|
direction = SignalDirection.BULLISH
|
||||||
|
else:
|
||||||
|
direction = SignalDirection.BEARISH
|
||||||
|
|
||||||
|
# Confidence: higher when the nearest level is a key ratio (0.618, 0.5)
|
||||||
|
if nearest_ratio in _KEY_RATIOS:
|
||||||
|
confidence = min(1.0, strength * 1.2)
|
||||||
|
else:
|
||||||
|
confidence = strength * 0.8
|
||||||
|
|
||||||
|
return SignalResult(
|
||||||
|
signal_type="fibonacci",
|
||||||
|
timeframe=timeframe,
|
||||||
|
strength=strength,
|
||||||
|
direction=direction,
|
||||||
|
confidence=confidence,
|
||||||
|
metadata={
|
||||||
|
"swing_high": sh_price,
|
||||||
|
"swing_low": sl_price,
|
||||||
|
"retracement_levels": levels,
|
||||||
|
"nearest_ratio": nearest_ratio,
|
||||||
|
"nearest_level": nearest_level,
|
||||||
|
"distance_to_nearest": min_distance,
|
||||||
|
"current_price": current_price,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Moving average stack signal evaluator.
|
||||||
|
|
||||||
|
Detects bullish alignment (MA_10 > MA_20 > MA_50 > MA_200) and bearish
|
||||||
|
alignment (MA_10 < MA_20 < MA_50 < MA_200), producing a signal strength
|
||||||
|
proportional to the degree of alignment.
|
||||||
|
|
||||||
|
Full alignment (4/4 MAs in order) yields strength 1.0, partial alignment
|
||||||
|
(3/4) yields 0.6, and no alignment returns ``None`` (no signal).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||||
|
from services.signal_engine.signals.base import compute_sma, validate_lookback
|
||||||
|
|
||||||
|
# MA periods used for stack evaluation
|
||||||
|
MA_PERIODS: list[int] = [10, 20, 50, 200]
|
||||||
|
|
||||||
|
# Minimum number of bars required (longest MA period)
|
||||||
|
MIN_BARS: int = 200
|
||||||
|
|
||||||
|
# Strength values
|
||||||
|
_FULL_ALIGNMENT_STRENGTH: float = 1.0
|
||||||
|
_PARTIAL_ALIGNMENT_STRENGTH: float = 0.6
|
||||||
|
|
||||||
|
# Confidence multiplier (high confidence for clear alignment patterns)
|
||||||
|
_CONFIDENCE_MULTIPLIER: float = 0.9
|
||||||
|
|
||||||
|
|
||||||
|
class MAStackEvaluator:
|
||||||
|
"""Moving average stack signal evaluator.
|
||||||
|
|
||||||
|
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||||
|
protocol.
|
||||||
|
|
||||||
|
Computes MA_10, MA_20, MA_50, and MA_200 and checks whether they are
|
||||||
|
in bullish or bearish order. Full alignment (all four in strict order)
|
||||||
|
produces strength 1.0; partial alignment (any three consecutive in order)
|
||||||
|
produces strength 0.6. When no alignment is detected the evaluator
|
||||||
|
returns ``None``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API (SignalEvaluator protocol)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
timeframe: str,
|
||||||
|
) -> SignalResult | None:
|
||||||
|
"""Evaluate moving average stack alignment on *bars*.
|
||||||
|
|
||||||
|
Returns ``None`` when there are fewer than 200 bars (insufficient
|
||||||
|
data for MA_200) or when no alignment is detected.
|
||||||
|
"""
|
||||||
|
if not validate_lookback(bars, MIN_BARS):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Compute all four moving averages
|
||||||
|
ma_10 = compute_sma(bars, 10)
|
||||||
|
ma_20 = compute_sma(bars, 20)
|
||||||
|
ma_50 = compute_sma(bars, 50)
|
||||||
|
ma_200 = compute_sma(bars, 200)
|
||||||
|
|
||||||
|
# Safety check — compute_sma returns None on insufficient data
|
||||||
|
if ma_10 is None or ma_20 is None or ma_50 is None or ma_200 is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ma_values = [ma_10, ma_20, ma_50, ma_200]
|
||||||
|
|
||||||
|
# Check full bullish alignment: MA_10 > MA_20 > MA_50 > MA_200
|
||||||
|
full_bullish = ma_10 > ma_20 > ma_50 > ma_200
|
||||||
|
|
||||||
|
# Check full bearish alignment: MA_10 < MA_20 < MA_50 < MA_200
|
||||||
|
full_bearish = ma_10 < ma_20 < ma_50 < ma_200
|
||||||
|
|
||||||
|
if full_bullish:
|
||||||
|
return self._build_result(
|
||||||
|
direction=SignalDirection.BULLISH,
|
||||||
|
strength=_FULL_ALIGNMENT_STRENGTH,
|
||||||
|
alignment="full_bullish",
|
||||||
|
timeframe=timeframe,
|
||||||
|
ma_values=ma_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
if full_bearish:
|
||||||
|
return self._build_result(
|
||||||
|
direction=SignalDirection.BEARISH,
|
||||||
|
strength=_FULL_ALIGNMENT_STRENGTH,
|
||||||
|
alignment="full_bearish",
|
||||||
|
timeframe=timeframe,
|
||||||
|
ma_values=ma_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check partial alignment (3 out of 4 consecutive MAs in order)
|
||||||
|
partial_bullish = self._check_partial_bullish(ma_values)
|
||||||
|
partial_bearish = self._check_partial_bearish(ma_values)
|
||||||
|
|
||||||
|
if partial_bullish:
|
||||||
|
return self._build_result(
|
||||||
|
direction=SignalDirection.BULLISH,
|
||||||
|
strength=_PARTIAL_ALIGNMENT_STRENGTH,
|
||||||
|
alignment="partial_bullish",
|
||||||
|
timeframe=timeframe,
|
||||||
|
ma_values=ma_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
if partial_bearish:
|
||||||
|
return self._build_result(
|
||||||
|
direction=SignalDirection.BEARISH,
|
||||||
|
strength=_PARTIAL_ALIGNMENT_STRENGTH,
|
||||||
|
alignment="partial_bearish",
|
||||||
|
timeframe=timeframe,
|
||||||
|
ma_values=ma_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No alignment detected — no signal
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_partial_bullish(ma_values: list[float]) -> bool:
|
||||||
|
"""Return ``True`` if any 3 consecutive MAs are in bullish order.
|
||||||
|
|
||||||
|
Checks windows [0:3] and [1:4] of the ordered MA list
|
||||||
|
(MA_10, MA_20, MA_50, MA_200) for strictly descending values
|
||||||
|
(higher MA value = bullish when shorter period > longer period).
|
||||||
|
"""
|
||||||
|
# Window 1: MA_10 > MA_20 > MA_50
|
||||||
|
if ma_values[0] > ma_values[1] > ma_values[2]:
|
||||||
|
return True
|
||||||
|
# Window 2: MA_20 > MA_50 > MA_200
|
||||||
|
if ma_values[1] > ma_values[2] > ma_values[3]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_partial_bearish(ma_values: list[float]) -> bool:
|
||||||
|
"""Return ``True`` if any 3 consecutive MAs are in bearish order.
|
||||||
|
|
||||||
|
Checks windows [0:3] and [1:4] of the ordered MA list
|
||||||
|
for strictly ascending values (lower MA value = bearish when
|
||||||
|
shorter period < longer period).
|
||||||
|
"""
|
||||||
|
# Window 1: MA_10 < MA_20 < MA_50
|
||||||
|
if ma_values[0] < ma_values[1] < ma_values[2]:
|
||||||
|
return True
|
||||||
|
# Window 2: MA_20 < MA_50 < MA_200
|
||||||
|
if ma_values[1] < ma_values[2] < ma_values[3]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_result(
|
||||||
|
*,
|
||||||
|
direction: SignalDirection,
|
||||||
|
strength: float,
|
||||||
|
alignment: str,
|
||||||
|
timeframe: str,
|
||||||
|
ma_values: list[float],
|
||||||
|
) -> SignalResult:
|
||||||
|
"""Construct a ``SignalResult`` for the MA stack signal."""
|
||||||
|
confidence = strength * _CONFIDENCE_MULTIPLIER
|
||||||
|
|
||||||
|
return SignalResult(
|
||||||
|
signal_type="ma_stack",
|
||||||
|
timeframe=timeframe,
|
||||||
|
strength=strength,
|
||||||
|
direction=direction,
|
||||||
|
confidence=confidence,
|
||||||
|
metadata={
|
||||||
|
"ma_10": ma_values[0],
|
||||||
|
"ma_20": ma_values[1],
|
||||||
|
"ma_50": ma_values[2],
|
||||||
|
"ma_200": ma_values[3],
|
||||||
|
"alignment": alignment,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""RSI (Relative Strength Index) signal evaluator.
|
||||||
|
|
||||||
|
Computes the standard 14-period RSI using Wilder's smoothing method and
|
||||||
|
produces overbought (RSI > 70 → BEARISH) or oversold (RSI < 30 → BULLISH)
|
||||||
|
signals with strength scaled by distance from the threshold.
|
||||||
|
|
||||||
|
When RSI is between 30 and 70 (neutral zone), no signal is produced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||||
|
from services.signal_engine.signals.base import validate_lookback
|
||||||
|
|
||||||
|
# Default RSI period (standard Wilder 14-period)
|
||||||
|
DEFAULT_RSI_PERIOD: int = 14
|
||||||
|
|
||||||
|
# Minimum bars required: period + 1 (for initial price change calculation)
|
||||||
|
DEFAULT_MIN_BARS: int = DEFAULT_RSI_PERIOD + 1 # 15
|
||||||
|
|
||||||
|
# Overbought / oversold thresholds
|
||||||
|
OVERBOUGHT_THRESHOLD: float = 70.0
|
||||||
|
OVERSOLD_THRESHOLD: float = 30.0
|
||||||
|
|
||||||
|
# Maximum possible distance from threshold (used for strength scaling)
|
||||||
|
_MAX_DISTANCE_OVERBOUGHT: float = 100.0 - OVERBOUGHT_THRESHOLD # 30
|
||||||
|
_MAX_DISTANCE_OVERSOLD: float = OVERSOLD_THRESHOLD - 0.0 # 30
|
||||||
|
|
||||||
|
# Confidence multiplier
|
||||||
|
_CONFIDENCE_MULTIPLIER: float = 0.85
|
||||||
|
|
||||||
|
|
||||||
|
def compute_rsi(bars: list[OHLCVBar], period: int = DEFAULT_RSI_PERIOD) -> float | None:
|
||||||
|
"""Compute RSI using Wilder's smoothing method.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bars: OHLCV bar series (oldest-first).
|
||||||
|
period: RSI period (default 14).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RSI value in [0, 100], or ``None`` if insufficient data.
|
||||||
|
"""
|
||||||
|
min_bars = period + 1
|
||||||
|
if len(bars) < min_bars:
|
||||||
|
return None
|
||||||
|
|
||||||
|
closes = [bar.close for bar in bars]
|
||||||
|
|
||||||
|
# Calculate price changes
|
||||||
|
changes = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
|
||||||
|
|
||||||
|
# Separate gains and losses for the first `period` changes
|
||||||
|
first_gains = [max(0.0, c) for c in changes[:period]]
|
||||||
|
first_losses = [max(0.0, -c) for c in changes[:period]]
|
||||||
|
|
||||||
|
avg_gain = sum(first_gains) / period
|
||||||
|
avg_loss = sum(first_losses) / period
|
||||||
|
|
||||||
|
# Apply Wilder smoothing for subsequent changes
|
||||||
|
for c in changes[period:]:
|
||||||
|
gain = max(0.0, c)
|
||||||
|
loss = max(0.0, -c)
|
||||||
|
avg_gain = (avg_gain * (period - 1) + gain) / period
|
||||||
|
avg_loss = (avg_loss * (period - 1) + loss) / period
|
||||||
|
|
||||||
|
# Avoid division by zero: if avg_loss is 0, RSI is 100
|
||||||
|
if avg_loss == 0.0:
|
||||||
|
return 100.0
|
||||||
|
|
||||||
|
rs = avg_gain / avg_loss
|
||||||
|
rsi = 100.0 - (100.0 / (1.0 + rs))
|
||||||
|
return rsi
|
||||||
|
|
||||||
|
|
||||||
|
class RSIEvaluator:
|
||||||
|
"""RSI signal evaluator.
|
||||||
|
|
||||||
|
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||||
|
protocol.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
period:
|
||||||
|
RSI calculation period. Defaults to ``14``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, period: int = DEFAULT_RSI_PERIOD) -> None:
|
||||||
|
self.period = period
|
||||||
|
self.min_bars = period + 1
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API (SignalEvaluator protocol)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
bars: list[OHLCVBar],
|
||||||
|
timeframe: str,
|
||||||
|
) -> SignalResult | None:
|
||||||
|
"""Evaluate RSI on *bars* for *timeframe*.
|
||||||
|
|
||||||
|
Returns ``None`` when there are fewer than ``period + 1`` bars
|
||||||
|
or when RSI is in the neutral zone (30–70).
|
||||||
|
"""
|
||||||
|
if not validate_lookback(bars, self.min_bars):
|
||||||
|
return None
|
||||||
|
|
||||||
|
rsi = compute_rsi(bars, self.period)
|
||||||
|
if rsi is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Overbought: RSI > 70 → BEARISH (potential reversal down)
|
||||||
|
if rsi > OVERBOUGHT_THRESHOLD:
|
||||||
|
distance = rsi - OVERBOUGHT_THRESHOLD
|
||||||
|
strength = min(1.0, max(0.0, distance / _MAX_DISTANCE_OVERBOUGHT))
|
||||||
|
confidence = strength * _CONFIDENCE_MULTIPLIER
|
||||||
|
return SignalResult(
|
||||||
|
signal_type="rsi",
|
||||||
|
timeframe=timeframe,
|
||||||
|
strength=strength,
|
||||||
|
direction=SignalDirection.BEARISH,
|
||||||
|
confidence=confidence,
|
||||||
|
metadata={
|
||||||
|
"rsi": rsi,
|
||||||
|
"period": self.period,
|
||||||
|
"zone": "overbought",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Oversold: RSI < 30 → BULLISH (potential reversal up)
|
||||||
|
if rsi < OVERSOLD_THRESHOLD:
|
||||||
|
distance = OVERSOLD_THRESHOLD - rsi
|
||||||
|
strength = min(1.0, max(0.0, distance / _MAX_DISTANCE_OVERSOLD))
|
||||||
|
confidence = strength * _CONFIDENCE_MULTIPLIER
|
||||||
|
return SignalResult(
|
||||||
|
signal_type="rsi",
|
||||||
|
timeframe=timeframe,
|
||||||
|
strength=strength,
|
||||||
|
direction=SignalDirection.BULLISH,
|
||||||
|
confidence=confidence,
|
||||||
|
metadata={
|
||||||
|
"rsi": rsi,
|
||||||
|
"period": self.period,
|
||||||
|
"zone": "oversold",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Neutral zone (30 ≤ RSI ≤ 70): no signal
|
||||||
|
return None
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Top-level orchestrator for a single evaluation tick.
|
||||||
|
|
||||||
|
Coordinates input normalization, exit evaluation, hard filters, signal
|
||||||
|
evaluation, both pipelines (concurrent), delta analysis, output formatting,
|
||||||
|
persistence, and Redis queue publication.
|
||||||
|
|
||||||
|
Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import redis.asyncio
|
||||||
|
|
||||||
|
from services.aggregation.regime import classify_regime
|
||||||
|
from services.signal_engine.config import SignalEngineConfig
|
||||||
|
from services.signal_engine.confluence import compute_confluence
|
||||||
|
from services.signal_engine.delta import analyze_delta
|
||||||
|
from services.signal_engine.exit_engine import evaluate_exits
|
||||||
|
from services.signal_engine.formatter import (
|
||||||
|
format_output,
|
||||||
|
signal_output_to_recommendation,
|
||||||
|
)
|
||||||
|
from services.signal_engine.hard_filter import evaluate_hard_filters
|
||||||
|
from services.signal_engine.heuristic import run_heuristic_pipeline
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
HeuristicResult,
|
||||||
|
NormalizedInput,
|
||||||
|
ProbabilisticResult,
|
||||||
|
SignalOutput,
|
||||||
|
SignalResult,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
from services.signal_engine.normalizer import normalize_input
|
||||||
|
from services.signal_engine.persistence import persist_signal_output
|
||||||
|
from services.signal_engine.probabilistic import run_probabilistic_pipeline
|
||||||
|
from services.signal_engine.signals.cup_handle import CupHandleEvaluator
|
||||||
|
from services.signal_engine.signals.elliott_wave import ElliottWaveEvaluator
|
||||||
|
from services.signal_engine.signals.fibonacci import FibonacciEvaluator
|
||||||
|
from services.signal_engine.signals.ma_stack import MAStackEvaluator
|
||||||
|
from services.signal_engine.signals.rsi import RSIEvaluator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Redis queue for trading decisions
|
||||||
|
_TRADING_QUEUE = "stonks:queue:trading_decisions"
|
||||||
|
|
||||||
|
# All signal evaluators
|
||||||
|
_EVALUATORS = [
|
||||||
|
FibonacciEvaluator(),
|
||||||
|
MAStackEvaluator(),
|
||||||
|
RSIEvaluator(),
|
||||||
|
CupHandleEvaluator(),
|
||||||
|
ElliottWaveEvaluator(),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Default SKIP results used when a pipeline fails
|
||||||
|
_SKIP_HEURISTIC = HeuristicResult(
|
||||||
|
verdict=Verdict.SKIP,
|
||||||
|
confidence=0.0,
|
||||||
|
s_total=0.0,
|
||||||
|
s_company=0.0,
|
||||||
|
s_macro=0.0,
|
||||||
|
s_competitive=0.0,
|
||||||
|
signal_weights=[],
|
||||||
|
reasoning=["pipeline_error: heuristic pipeline raised an exception"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_SKIP_PROBABILISTIC = ProbabilisticResult(
|
||||||
|
verdict=Verdict.SKIP,
|
||||||
|
p_up=0.5,
|
||||||
|
entropy=1.0,
|
||||||
|
ev_r=0.0,
|
||||||
|
prior=0.5,
|
||||||
|
posterior=0.5,
|
||||||
|
likelihood_ratios=[],
|
||||||
|
regime="uncertainty",
|
||||||
|
reasoning=["pipeline_error: probabilistic pipeline raised an exception"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_signals(
|
||||||
|
normalized: NormalizedInput,
|
||||||
|
) -> dict[str, dict[str, SignalResult]]:
|
||||||
|
"""Run all signal evaluators across all timeframes.
|
||||||
|
|
||||||
|
Returns ``{signal_type: {timeframe: SignalResult}}`` for signals that
|
||||||
|
fired. Signals that returned ``None`` (insufficient data or no trigger)
|
||||||
|
are omitted.
|
||||||
|
"""
|
||||||
|
from services.signal_engine.normalizer import TIMEFRAMES
|
||||||
|
|
||||||
|
results: dict[str, dict[str, SignalResult]] = {}
|
||||||
|
|
||||||
|
for evaluator in _EVALUATORS:
|
||||||
|
for tf in TIMEFRAMES:
|
||||||
|
bars = normalized.bars.get(tf, [])
|
||||||
|
if not bars:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = evaluator.evaluate(bars, tf)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Signal evaluator %s failed on %s/%s",
|
||||||
|
type(evaluator).__name__,
|
||||||
|
normalized.ticker,
|
||||||
|
tf,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
results.setdefault(result.signal_type, {})[tf] = result
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def evaluate_tick(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
redis_client: redis.asyncio.Redis,
|
||||||
|
ticker: str,
|
||||||
|
config: SignalEngineConfig,
|
||||||
|
) -> SignalOutput | None:
|
||||||
|
"""Run a full evaluation tick for a single ticker.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Normalize inputs (single fetch, shared reference)
|
||||||
|
2. Evaluate exit conditions for open positions
|
||||||
|
3. Run hard filters (short-circuit if filtered)
|
||||||
|
4. Evaluate signals across timeframes via Signal Library
|
||||||
|
5. Compute confluence
|
||||||
|
6. Classify regime via existing ``classify_regime()``
|
||||||
|
7. Run both pipelines concurrently via ``asyncio.gather``
|
||||||
|
8. Compute delta analysis
|
||||||
|
9. Format output
|
||||||
|
10. Persist to database and publish to Redis queue
|
||||||
|
|
||||||
|
Returns ``None`` if the ticker is hard-filtered or both pipelines fail.
|
||||||
|
|
||||||
|
Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6
|
||||||
|
"""
|
||||||
|
tick_start = time.monotonic()
|
||||||
|
|
||||||
|
# Step 1: Normalize inputs
|
||||||
|
normalized = await normalize_input(pool, ticker, config)
|
||||||
|
|
||||||
|
# Step 2: Evaluate exit conditions (before pipelines — Req 8.6)
|
||||||
|
current_price = normalized.current_price or 0.0
|
||||||
|
exit_signals = evaluate_exits(
|
||||||
|
normalized.open_positions,
|
||||||
|
{ticker: current_price},
|
||||||
|
config.exit_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Hard filters
|
||||||
|
filter_result = evaluate_hard_filters(normalized, config.hard_filter_config)
|
||||||
|
if filter_result.filtered:
|
||||||
|
logger.info(
|
||||||
|
"Ticker %s hard-filtered: %s",
|
||||||
|
ticker,
|
||||||
|
", ".join(filter_result.reasons),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 4: Evaluate signals across timeframes
|
||||||
|
signal_results = _evaluate_signals(normalized)
|
||||||
|
|
||||||
|
# Step 5: Compute confluence
|
||||||
|
confluence_signals = compute_confluence(signal_results, config.timeframe_weights)
|
||||||
|
|
||||||
|
# Step 6: Classify regime
|
||||||
|
regime = classify_regime(normalized.closing_prices, normalized.returns)
|
||||||
|
|
||||||
|
# Step 7: Run both pipelines concurrently
|
||||||
|
heuristic_start = time.monotonic()
|
||||||
|
|
||||||
|
async def _run_heuristic() -> HeuristicResult:
|
||||||
|
return run_heuristic_pipeline(
|
||||||
|
normalized, confluence_signals, config.heuristic_config
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_probabilistic() -> ProbabilisticResult:
|
||||||
|
return run_probabilistic_pipeline(
|
||||||
|
normalized, confluence_signals, regime, config.probabilistic_config
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
_run_heuristic(),
|
||||||
|
_run_probabilistic(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline_elapsed = time.monotonic() - heuristic_start
|
||||||
|
|
||||||
|
# Handle pipeline exceptions — SKIP verdict for failed pipeline
|
||||||
|
heuristic_result: HeuristicResult
|
||||||
|
probabilistic_result: ProbabilisticResult
|
||||||
|
|
||||||
|
if isinstance(results[0], BaseException):
|
||||||
|
logger.error(
|
||||||
|
"Heuristic pipeline failed for %s: %s",
|
||||||
|
ticker,
|
||||||
|
results[0],
|
||||||
|
exc_info=results[0],
|
||||||
|
)
|
||||||
|
heuristic_result = _SKIP_HEURISTIC
|
||||||
|
else:
|
||||||
|
heuristic_result = results[0]
|
||||||
|
|
||||||
|
if isinstance(results[1], BaseException):
|
||||||
|
logger.error(
|
||||||
|
"Probabilistic pipeline failed for %s: %s",
|
||||||
|
ticker,
|
||||||
|
results[1],
|
||||||
|
exc_info=results[1],
|
||||||
|
)
|
||||||
|
probabilistic_result = _SKIP_PROBABILISTIC
|
||||||
|
else:
|
||||||
|
probabilistic_result = results[1]
|
||||||
|
|
||||||
|
# If both pipelines failed, return None
|
||||||
|
if isinstance(results[0], BaseException) and isinstance(results[1], BaseException):
|
||||||
|
logger.error(
|
||||||
|
"Both pipelines failed for %s — skipping tick",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Pipelines completed for %s in %.3fs — heuristic=%s, probabilistic=%s",
|
||||||
|
ticker,
|
||||||
|
pipeline_elapsed,
|
||||||
|
heuristic_result.verdict.value,
|
||||||
|
probabilistic_result.verdict.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 8: Delta analysis
|
||||||
|
delta = await analyze_delta(
|
||||||
|
heuristic_result, probabilistic_result, redis_client, ticker
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 9: Format output
|
||||||
|
price = normalized.current_price or 0.0
|
||||||
|
output = format_output(
|
||||||
|
ticker,
|
||||||
|
price,
|
||||||
|
heuristic_result,
|
||||||
|
probabilistic_result,
|
||||||
|
delta,
|
||||||
|
exit_signals,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 10: Persist to database
|
||||||
|
await persist_signal_output(pool, output)
|
||||||
|
|
||||||
|
# Step 11: Publish to trading queue (only if at least one BUY and not shadow_mode)
|
||||||
|
has_buy = (
|
||||||
|
heuristic_result.verdict == Verdict.BUY
|
||||||
|
or probabilistic_result.verdict == Verdict.BUY
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_buy and not config.shadow_mode:
|
||||||
|
try:
|
||||||
|
recommendation = signal_output_to_recommendation(output)
|
||||||
|
await redis_client.rpush(
|
||||||
|
_TRADING_QUEUE,
|
||||||
|
recommendation.model_dump_json(),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Published trading recommendation for %s to %s",
|
||||||
|
ticker,
|
||||||
|
_TRADING_QUEUE,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Failed to publish trading recommendation for %s",
|
||||||
|
ticker,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
elif has_buy and config.shadow_mode:
|
||||||
|
logger.info(
|
||||||
|
"Shadow mode: BUY signal for %s persisted but not published to trading queue",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log wall-clock execution time
|
||||||
|
tick_elapsed = time.monotonic() - tick_start
|
||||||
|
logger.info(
|
||||||
|
"Evaluation tick for %s completed in %.3fs",
|
||||||
|
ticker,
|
||||||
|
tick_elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
return output
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Feature: dual-pipeline-signal-engine, Properties: Bayesian log-odds, entropy gate, EV_R
|
||||||
|
"""Property-based tests for the probabilistic pipeline math.
|
||||||
|
|
||||||
|
Feature: dual-pipeline-signal-engine
|
||||||
|
|
||||||
|
Tests three properties from the design specification:
|
||||||
|
1. Bayesian log-odds round-trip (Requirement 17.2)
|
||||||
|
2. Shannon entropy gate properties (Requirement 17.3)
|
||||||
|
3. EV_R monotonicity with P_up (Requirement 17.8)
|
||||||
|
|
||||||
|
Requirements: 6.3, 6.4, 6.5, 17.2, 17.3, 17.8
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from hypothesis import given, settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
from services.signal_engine.probabilistic import (
|
||||||
|
_logit,
|
||||||
|
_shannon_entropy,
|
||||||
|
_sigmoid,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hypothesis strategies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Prior probability in (0.01, 0.99) — avoids extreme clamping at boundaries
|
||||||
|
_prior_prob = st.floats(
|
||||||
|
min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log-likelihood ratio values — bounded to avoid overflow in sigmoid
|
||||||
|
_log_lr = st.floats(
|
||||||
|
min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# List of log-LR values (1 to 10 signals)
|
||||||
|
_log_lr_list = st.lists(_log_lr, min_size=1, max_size=10)
|
||||||
|
|
||||||
|
# Probability in open interval (0, 1) for entropy tests
|
||||||
|
_open_prob = st.floats(
|
||||||
|
min_value=1e-6, max_value=1.0 - 1e-6, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# P_up values for EV_R monotonicity — two ordered values
|
||||||
|
_p_up_pair = st.tuples(
|
||||||
|
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||||
|
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||||
|
).filter(lambda pair: pair[0] < pair[1])
|
||||||
|
|
||||||
|
# Positive expected win in R-units
|
||||||
|
_e_win_r = st.floats(
|
||||||
|
min_value=0.01, max_value=100.0, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property 1: Bayesian log-odds round-trip
|
||||||
|
# Validates: Requirements 6.3, 17.2
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@given(p_prior=_prior_prob, log_lrs=_log_lr_list)
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_bayesian_log_odds_round_trip(
|
||||||
|
p_prior: float, log_lrs: list[float],
|
||||||
|
) -> None:
|
||||||
|
"""**Validates: Requirements 6.3, 17.2**
|
||||||
|
|
||||||
|
Converting P_prior to logit, adding Σ log(LR_i), and converting back
|
||||||
|
via sigmoid SHALL produce a valid probability in [0, 1].
|
||||||
|
|
||||||
|
logit(P_post) = logit(P_prior) + Σ log(LR_i)
|
||||||
|
P_post = sigmoid(logit(P_post))
|
||||||
|
|
||||||
|
The sigmoid implementation clamps extreme values to 0.0 / 1.0, so the
|
||||||
|
result is in the closed interval [0, 1].
|
||||||
|
"""
|
||||||
|
logit_prior = _logit(p_prior)
|
||||||
|
sum_log_lr = sum(log_lrs)
|
||||||
|
logit_posterior = logit_prior + sum_log_lr
|
||||||
|
p_posterior = _sigmoid(logit_posterior)
|
||||||
|
|
||||||
|
# Posterior must be a valid probability in [0, 1]
|
||||||
|
assert 0.0 <= p_posterior <= 1.0, (
|
||||||
|
f"Posterior {p_posterior} not in [0, 1]. "
|
||||||
|
f"P_prior={p_prior}, logit_prior={logit_prior}, "
|
||||||
|
f"Σ log_lr={sum_log_lr}, logit_posterior={logit_posterior}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# For non-saturated posteriors, round-trip should hold:
|
||||||
|
# sigmoid(logit(p)) ≈ p. At saturation (0.0 or 1.0) the logit
|
||||||
|
# clamps, so we only check the interior.
|
||||||
|
if 1e-9 < p_posterior < 1.0 - 1e-9:
|
||||||
|
round_trip = _sigmoid(_logit(p_posterior))
|
||||||
|
assert math.isclose(round_trip, p_posterior, rel_tol=1e-6), (
|
||||||
|
f"Round-trip failed: sigmoid(logit({p_posterior})) = {round_trip}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property 2: Shannon entropy gate properties
|
||||||
|
# Validates: Requirements 6.4, 17.3
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@given(p=_open_prob)
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_entropy_maximized_at_half(p: float) -> None:
|
||||||
|
"""**Validates: Requirements 6.4, 17.3**
|
||||||
|
|
||||||
|
Shannon entropy H(p) SHALL be maximized at p = 0.5.
|
||||||
|
For all p in (0, 1): H(0.5) >= H(p).
|
||||||
|
"""
|
||||||
|
h_p = _shannon_entropy(p)
|
||||||
|
h_half = _shannon_entropy(0.5)
|
||||||
|
|
||||||
|
assert h_half >= h_p - 1e-12, (
|
||||||
|
f"Entropy at 0.5 ({h_half}) should be >= entropy at {p} ({h_p})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_entropy_zero_at_boundaries() -> None:
|
||||||
|
"""**Validates: Requirements 6.4, 17.3**
|
||||||
|
|
||||||
|
Shannon entropy SHALL equal 0.0 at p = 0.0 and p = 1.0.
|
||||||
|
"""
|
||||||
|
assert _shannon_entropy(0.0) == 0.0, "H(0.0) should be 0.0"
|
||||||
|
assert _shannon_entropy(1.0) == 0.0, "H(1.0) should be 0.0"
|
||||||
|
|
||||||
|
|
||||||
|
@given(p=_open_prob)
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_entropy_symmetric_around_half(p: float) -> None:
|
||||||
|
"""**Validates: Requirements 6.4, 17.3**
|
||||||
|
|
||||||
|
Shannon entropy SHALL be symmetric around 0.5: H(p) == H(1 - p).
|
||||||
|
"""
|
||||||
|
h_p = _shannon_entropy(p)
|
||||||
|
h_complement = _shannon_entropy(1.0 - p)
|
||||||
|
|
||||||
|
assert math.isclose(h_p, h_complement, rel_tol=1e-9), (
|
||||||
|
f"Entropy not symmetric: H({p}) = {h_p}, H({1.0 - p}) = {h_complement}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property 3: EV_R monotonicity with P_up
|
||||||
|
# Validates: Requirements 6.5, 17.8
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@given(p_pair=_p_up_pair, e_win=_e_win_r)
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_ev_r_monotonically_increasing_with_p_up(
|
||||||
|
p_pair: tuple[float, float], e_win: float,
|
||||||
|
) -> None:
|
||||||
|
"""**Validates: Requirements 6.5, 17.8**
|
||||||
|
|
||||||
|
EV_R = P_up · E[win_R] - (1 - P_up) · 1.0 SHALL be monotonically
|
||||||
|
increasing with P_up for fixed E[win_R] > 0.
|
||||||
|
|
||||||
|
For p1 < p2 and fixed E[win_R] > 0: EV_R(p2) >= EV_R(p1).
|
||||||
|
"""
|
||||||
|
p1, p2 = p_pair
|
||||||
|
|
||||||
|
# Compute EV_R directly using the formula (not _compute_ev_r which
|
||||||
|
# derives E[win_R] from confluence signals)
|
||||||
|
ev_r_1 = p1 * e_win - (1.0 - p1) * 1.0
|
||||||
|
ev_r_2 = p2 * e_win - (1.0 - p2) * 1.0
|
||||||
|
|
||||||
|
assert ev_r_2 >= ev_r_1 - 1e-12, (
|
||||||
|
f"EV_R not monotonic: EV_R(p2={p2}) = {ev_r_2} < "
|
||||||
|
f"EV_R(p1={p1}) = {ev_r_1} with E[win_R]={e_win}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Feature: dual-pipeline-signal-engine, Property: Confluence score monotonicity
|
||||||
|
"""Property-based tests for the Multi-Timeframe Confluence Engine.
|
||||||
|
|
||||||
|
Feature: dual-pipeline-signal-engine
|
||||||
|
|
||||||
|
Tests the confluence score monotonicity property from the design specification:
|
||||||
|
activating a signal on an additional timeframe with non-zero weight always
|
||||||
|
increases or maintains the confluence score.
|
||||||
|
|
||||||
|
Requirements: 3.6, 17.5
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from hypothesis import given, settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
from services.signal_engine.confluence import compute_confluence
|
||||||
|
from services.signal_engine.models import SignalDirection, SignalResult
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property: Confluence score monotonicity
|
||||||
|
# Validates: Requirements 3.6, 17.5
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Default timeframe weights per the design specification
|
||||||
|
DEFAULT_WEIGHTS: dict[str, float] = {
|
||||||
|
"M30": 0.03,
|
||||||
|
"H1": 0.07,
|
||||||
|
"H4": 0.15,
|
||||||
|
"D": 0.30,
|
||||||
|
"W": 0.30,
|
||||||
|
"M": 0.15,
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_TIMEFRAMES = list(DEFAULT_WEIGHTS.keys())
|
||||||
|
ANCHOR_TIMEFRAMES = ["D", "W", "M"]
|
||||||
|
NON_ANCHOR_TIMEFRAMES = ["M30", "H1", "H4"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hypothesis strategies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_direction = st.sampled_from([SignalDirection.BULLISH, SignalDirection.BEARISH])
|
||||||
|
|
||||||
|
_nonzero_strength = st.floats(
|
||||||
|
min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_confidence = st.floats(
|
||||||
|
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_signal_type = st.just("test_signal")
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_result(
|
||||||
|
timeframe: str,
|
||||||
|
strength: st.SearchStrategy[float] = _nonzero_strength,
|
||||||
|
) -> st.SearchStrategy[SignalResult]:
|
||||||
|
"""Build a SignalResult for a given timeframe with non-zero strength."""
|
||||||
|
return st.builds(
|
||||||
|
SignalResult,
|
||||||
|
signal_type=_signal_type,
|
||||||
|
timeframe=st.just(timeframe),
|
||||||
|
strength=strength,
|
||||||
|
direction=_direction,
|
||||||
|
confidence=_confidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.composite
|
||||||
|
def _base_and_extra_timeframe(draw: st.DrawFn) -> tuple[dict[str, SignalResult], str]:
|
||||||
|
"""Generate a base set of signal results that passes confluence, plus one extra timeframe.
|
||||||
|
|
||||||
|
The base set has at least 2 timeframes including at least one D/W/M anchor.
|
||||||
|
The extra timeframe is not in the base set and has a non-zero weight.
|
||||||
|
"""
|
||||||
|
# Pick 1 anchor timeframe (guaranteed)
|
||||||
|
anchor = draw(st.sampled_from(ANCHOR_TIMEFRAMES))
|
||||||
|
|
||||||
|
# Pick 1-4 additional timeframes from the remaining (to get at least 2 total)
|
||||||
|
remaining = [tf for tf in ALL_TIMEFRAMES if tf != anchor]
|
||||||
|
additional_count = draw(st.integers(min_value=1, max_value=min(4, len(remaining))))
|
||||||
|
additional = draw(
|
||||||
|
st.lists(
|
||||||
|
st.sampled_from(remaining),
|
||||||
|
min_size=additional_count,
|
||||||
|
max_size=additional_count,
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
base_tfs = [anchor] + additional
|
||||||
|
|
||||||
|
# Build signal results for the base set
|
||||||
|
base_results: dict[str, SignalResult] = {}
|
||||||
|
for tf in base_tfs:
|
||||||
|
base_results[tf] = draw(_signal_result(tf))
|
||||||
|
|
||||||
|
# Pick an extra timeframe NOT in the base set
|
||||||
|
unused = [tf for tf in ALL_TIMEFRAMES if tf not in base_tfs]
|
||||||
|
if not unused:
|
||||||
|
# All 6 timeframes used — remove one non-anchor from base to free it up
|
||||||
|
removable = [tf for tf in base_tfs if tf not in ANCHOR_TIMEFRAMES]
|
||||||
|
if not removable:
|
||||||
|
# All are anchors — remove one that isn't the primary anchor
|
||||||
|
removable = [tf for tf in base_tfs if tf != anchor]
|
||||||
|
to_remove = draw(st.sampled_from(removable))
|
||||||
|
del base_results[to_remove]
|
||||||
|
unused = [to_remove]
|
||||||
|
|
||||||
|
extra_tf = draw(st.sampled_from(unused))
|
||||||
|
|
||||||
|
return base_results, extra_tf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@given(data=st.data(), base_and_extra=_base_and_extra_timeframe())
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_confluence_score_monotonicity(
|
||||||
|
data: st.DataObject,
|
||||||
|
base_and_extra: tuple[dict[str, SignalResult], str],
|
||||||
|
) -> None:
|
||||||
|
"""**Validates: Requirements 3.6, 17.5**
|
||||||
|
|
||||||
|
Given a signal that already passes confluence (≥2 timeframes, ≥1 D/W/M
|
||||||
|
anchor), adding an additional timeframe with non-zero strength and
|
||||||
|
non-zero weight SHALL always increase or maintain the confluence score.
|
||||||
|
|
||||||
|
The weighted confluence score is C = Σ(w_tf · s_tf). Since both w_tf > 0
|
||||||
|
and s_tf > 0 for the added timeframe, the new term is strictly positive,
|
||||||
|
so the score must increase.
|
||||||
|
"""
|
||||||
|
base_results, extra_tf = base_and_extra
|
||||||
|
|
||||||
|
signal_type = "test_signal"
|
||||||
|
|
||||||
|
# Compute confluence for the base set
|
||||||
|
base_input = {signal_type: dict(base_results)}
|
||||||
|
base_confluence = compute_confluence(base_input, DEFAULT_WEIGHTS)
|
||||||
|
|
||||||
|
# The base set should pass confluence (≥2 TFs, ≥1 anchor)
|
||||||
|
assert len(base_confluence) == 1, (
|
||||||
|
f"Expected base set to pass confluence but got {len(base_confluence)} signals.\n"
|
||||||
|
f" Base timeframes: {list(base_results.keys())}"
|
||||||
|
)
|
||||||
|
base_score = base_confluence[0].confluence_score
|
||||||
|
|
||||||
|
# Add the extra timeframe with non-zero strength
|
||||||
|
extra_result = data.draw(_signal_result(extra_tf))
|
||||||
|
extended_results = dict(base_results)
|
||||||
|
extended_results[extra_tf] = extra_result
|
||||||
|
|
||||||
|
# Compute confluence for the extended set
|
||||||
|
extended_input = {signal_type: extended_results}
|
||||||
|
extended_confluence = compute_confluence(extended_input, DEFAULT_WEIGHTS)
|
||||||
|
|
||||||
|
# The extended set must also pass confluence (superset of a passing set)
|
||||||
|
assert len(extended_confluence) == 1, (
|
||||||
|
f"Expected extended set to pass confluence but got "
|
||||||
|
f"{len(extended_confluence)} signals.\n"
|
||||||
|
f" Extended timeframes: {list(extended_results.keys())}"
|
||||||
|
)
|
||||||
|
new_score = extended_confluence[0].confluence_score
|
||||||
|
|
||||||
|
# Monotonicity: new_score >= base_score
|
||||||
|
assert new_score >= base_score, (
|
||||||
|
f"Confluence score decreased when adding timeframe {extra_tf}!\n"
|
||||||
|
f" Base score: {base_score:.6f} (timeframes: {list(base_results.keys())})\n"
|
||||||
|
f" Extended score: {new_score:.6f} (timeframes: {list(extended_results.keys())})\n"
|
||||||
|
f" Added TF weight: {DEFAULT_WEIGHTS[extra_tf]}, "
|
||||||
|
f"strength: {extra_result.strength:.6f}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Feature: dual-pipeline-signal-engine, Property: Correlation penalty reduces confidence
|
||||||
|
"""Property-based tests for the signal correlation penalty.
|
||||||
|
|
||||||
|
Feature: dual-pipeline-signal-engine
|
||||||
|
|
||||||
|
Tests that the within-cluster correlation penalty always reduces (or maintains)
|
||||||
|
the posterior probability compared to the unpenalized posterior. Correlated
|
||||||
|
signals within the same cluster receive exponential decay (0.5^(n-1)), so the
|
||||||
|
penalized Σ log(LR_i) is always <= the unpenalized Σ log(LR_i) in absolute
|
||||||
|
magnitude, which means the posterior moves less from the prior.
|
||||||
|
|
||||||
|
Requirements: 7.5, 17.4
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from hypothesis import given, settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
from services.signal_engine.correlation import apply_correlation_penalty
|
||||||
|
from services.signal_engine.models import LikelihoodRatio
|
||||||
|
from services.signal_engine.probabilistic import _logit, _sigmoid
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hypothesis strategies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Clusters that have multiple signal types mapped to them
|
||||||
|
_CLUSTER_SIGNAL_TYPES: dict[str, list[str]] = {
|
||||||
|
"momentum": ["ma_stack", "rsi"],
|
||||||
|
"structure": ["fibonacci", "elliott_wave", "cup_handle"],
|
||||||
|
"volatility": ["atr", "bollinger"],
|
||||||
|
"fundamentals": ["valuation", "earnings", "macro"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pick a cluster that has at least 2 signal types
|
||||||
|
_cluster_with_types = st.sampled_from(
|
||||||
|
[(cluster, types) for cluster, types in _CLUSTER_SIGNAL_TYPES.items() if len(types) >= 2]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Positive log-LR values (bullish signals) — ensures same direction within cluster
|
||||||
|
_positive_log_lr = st.floats(
|
||||||
|
min_value=0.01, max_value=5.0, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prior probability
|
||||||
|
_prior_prob = st.floats(
|
||||||
|
min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.composite
|
||||||
|
def _correlated_lr_set(draw: st.DrawFn) -> list[LikelihoodRatio]:
|
||||||
|
"""Generate a list of LikelihoodRatio objects with at least 2 in the same cluster.
|
||||||
|
|
||||||
|
All signals within the chosen cluster have positive log_lr (bullish direction)
|
||||||
|
so the penalty effect is clearly measurable.
|
||||||
|
"""
|
||||||
|
cluster, signal_types = draw(_cluster_with_types)
|
||||||
|
|
||||||
|
# Draw at least 2 signals from the same cluster
|
||||||
|
n_correlated = draw(st.integers(min_value=2, max_value=len(signal_types)))
|
||||||
|
chosen_types = draw(
|
||||||
|
st.lists(
|
||||||
|
st.sampled_from(signal_types),
|
||||||
|
min_size=n_correlated,
|
||||||
|
max_size=n_correlated,
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lrs: list[LikelihoodRatio] = []
|
||||||
|
for sig_type in chosen_types:
|
||||||
|
log_lr = draw(_positive_log_lr)
|
||||||
|
lr_val = math.exp(log_lr)
|
||||||
|
lrs.append(
|
||||||
|
LikelihoodRatio(
|
||||||
|
signal_type=sig_type,
|
||||||
|
cluster=cluster,
|
||||||
|
lr=lr_val,
|
||||||
|
log_lr=log_lr,
|
||||||
|
penalized_log_lr=log_lr, # unpenalized initially
|
||||||
|
hit_rate=0.6,
|
||||||
|
strength=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return lrs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property: Correlation penalty reduces confidence
|
||||||
|
# Validates: Requirements 7.5, 17.4
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@given(lrs=_correlated_lr_set(), p_prior=_prior_prob)
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_penalized_posterior_leq_unpenalized(
|
||||||
|
lrs: list[LikelihoodRatio], p_prior: float,
|
||||||
|
) -> None:
|
||||||
|
"""**Validates: Requirements 7.5, 17.4**
|
||||||
|
|
||||||
|
For any signal set with at least 2 correlated signals in the same
|
||||||
|
cluster, the penalized posterior SHALL be <= the unpenalized posterior.
|
||||||
|
|
||||||
|
The penalty reduces the magnitude of Σ penalized_log_lr relative to
|
||||||
|
Σ log_lr, so the posterior moves less from the prior.
|
||||||
|
"""
|
||||||
|
# Unpenalized posterior: use raw log_lr values
|
||||||
|
logit_prior = _logit(p_prior)
|
||||||
|
sum_unpenalized = sum(lr.log_lr for lr in lrs)
|
||||||
|
p_unpenalized = _sigmoid(logit_prior + sum_unpenalized)
|
||||||
|
|
||||||
|
# Apply correlation penalty
|
||||||
|
penalized_lrs = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
# Penalized posterior: use penalized_log_lr values
|
||||||
|
sum_penalized = sum(lr.penalized_log_lr for lr in penalized_lrs)
|
||||||
|
p_penalized = _sigmoid(logit_prior + sum_penalized)
|
||||||
|
|
||||||
|
# Since all log_lr values are positive (bullish), the penalty reduces
|
||||||
|
# the sum, which means the penalized posterior is <= unpenalized posterior
|
||||||
|
assert p_penalized <= p_unpenalized + 1e-12, (
|
||||||
|
f"Penalized posterior {p_penalized} > unpenalized {p_unpenalized}. "
|
||||||
|
f"Prior={p_prior}, Σ_raw={sum_unpenalized}, Σ_penalized={sum_penalized}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also verify the penalized sum of log-LRs is <= the unpenalized sum
|
||||||
|
assert sum_penalized <= sum_unpenalized + 1e-12, (
|
||||||
|
f"Penalized Σ log_lr ({sum_penalized}) > unpenalized ({sum_unpenalized})"
|
||||||
|
)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Feature: dual-pipeline-signal-engine, Property: Fibonacci retracement bounds
|
||||||
|
"""Property-based tests for the Fibonacci retracement formula.
|
||||||
|
|
||||||
|
Feature: dual-pipeline-signal-engine
|
||||||
|
|
||||||
|
Tests the Fibonacci retracement bounds property from the design specification:
|
||||||
|
for all retracement ratios r in [0, 1] and all swing high SH > swing low SL > 0,
|
||||||
|
the retracement level L(r) = SH - r * (SH - SL) must lie within [SL, SH].
|
||||||
|
|
||||||
|
Requirements: 2.1, 17.1
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from hypothesis import given, settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property: Fibonacci retracement bounds
|
||||||
|
# Validates: Requirements 2.1, 17.1
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hypothesis strategies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Retracement ratio in [0, 1]
|
||||||
|
_ratio = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||||||
|
|
||||||
|
# Positive floats for swing high / swing low
|
||||||
|
_positive_float = st.floats(
|
||||||
|
min_value=1e-8, max_value=1e8, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.composite
|
||||||
|
def _swing_pair(draw: st.DrawFn) -> tuple[float, float]:
|
||||||
|
"""Generate (SH, SL) where SH > SL > 0."""
|
||||||
|
a = draw(_positive_float)
|
||||||
|
b = draw(_positive_float)
|
||||||
|
sh = max(a, b)
|
||||||
|
sl = min(a, b)
|
||||||
|
# Ensure strict inequality SH > SL
|
||||||
|
if sh == sl:
|
||||||
|
sh = sl + 1e-8
|
||||||
|
return sh, sl
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@given(r=_ratio, swing=_swing_pair())
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_fibonacci_retracement_within_bounds(r: float, swing: tuple[float, float]) -> None:
|
||||||
|
"""**Validates: Requirements 2.1, 17.1**
|
||||||
|
|
||||||
|
For all r in [0, 1] and all SH > SL > 0, the Fibonacci retracement
|
||||||
|
level L(r) = SH - r * (SH - SL) SHALL be in [SL, SH].
|
||||||
|
|
||||||
|
This is a pure mathematical property — no evaluator class needed.
|
||||||
|
"""
|
||||||
|
sh, sl = swing
|
||||||
|
|
||||||
|
# Compute the retracement level
|
||||||
|
level = sh - r * (sh - sl)
|
||||||
|
|
||||||
|
assert sl <= level <= sh, (
|
||||||
|
f"Fibonacci level {level} out of bounds [SL={sl}, SH={sh}] "
|
||||||
|
f"for r={r}.\n"
|
||||||
|
f" L(r) = {sh} - {r} * ({sh} - {sl}) = {level}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Feature: dual-pipeline-signal-engine, Property: Hard filter determinism
|
||||||
|
"""Property-based tests for the Hard Filter Engine.
|
||||||
|
|
||||||
|
Feature: dual-pipeline-signal-engine
|
||||||
|
|
||||||
|
Tests the hard filter determinism property from the design specification:
|
||||||
|
certain input conditions SHALL always produce a filtered (SKIP) result
|
||||||
|
regardless of all other field values in the NormalizedInput.
|
||||||
|
|
||||||
|
Requirements: 4.1, 4.2, 4.3, 17.7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from hypothesis import given, settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
from services.signal_engine.config import HardFilterConfig
|
||||||
|
from services.signal_engine.hard_filter import HardFilterResult, evaluate_hard_filters
|
||||||
|
from services.signal_engine.models import NormalizedInput, OHLCVBar, OpenPositionState
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property: Hard Filter Determinism
|
||||||
|
# Validates: Requirements 4.1, 4.2, 4.3, 17.7
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hypothesis strategies — building blocks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_finite_float = st.floats(allow_nan=False, allow_infinity=False)
|
||||||
|
|
||||||
|
_unit_float = st.floats(
|
||||||
|
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_positive_float = st.floats(
|
||||||
|
min_value=0.01, max_value=1e6, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_aware_datetime = st.datetimes(
|
||||||
|
min_value=datetime(2020, 1, 1),
|
||||||
|
max_value=datetime(2030, 12, 31),
|
||||||
|
timezones=st.just(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
_ticker = st.text(
|
||||||
|
alphabet=st.characters(whitelist_categories=("Lu",)),
|
||||||
|
min_size=1,
|
||||||
|
max_size=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- OHLCVBar strategy ---
|
||||||
|
|
||||||
|
_ohlcv_bar = st.builds(
|
||||||
|
OHLCVBar,
|
||||||
|
timestamp=_aware_datetime,
|
||||||
|
open=_positive_float,
|
||||||
|
high=_positive_float,
|
||||||
|
low=_positive_float,
|
||||||
|
close=_positive_float,
|
||||||
|
volume=_positive_float,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Bars dict strategy (0-3 bars per timeframe, 0-2 timeframes) ---
|
||||||
|
|
||||||
|
_bars_strategy = st.fixed_dictionaries(
|
||||||
|
{},
|
||||||
|
optional={
|
||||||
|
tf: st.lists(_ohlcv_bar, min_size=0, max_size=3)
|
||||||
|
for tf in ["M30", "H1", "H4", "D", "W", "M"]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- OpenPositionState strategy ---
|
||||||
|
|
||||||
|
_open_position = st.builds(
|
||||||
|
OpenPositionState,
|
||||||
|
position_id=st.uuids().map(str),
|
||||||
|
ticker=_ticker,
|
||||||
|
entry_price=_positive_float,
|
||||||
|
current_price=_positive_float,
|
||||||
|
stop_loss=_positive_float,
|
||||||
|
target_1=_positive_float,
|
||||||
|
target_2=_positive_float,
|
||||||
|
trailing_stop=st.one_of(st.none(), _positive_float),
|
||||||
|
partial_exit_done=st.booleans(),
|
||||||
|
atr=st.one_of(st.none(), _positive_float),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Base NormalizedInput strategy (all fields arbitrary) ---
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_input_strategy(
|
||||||
|
*,
|
||||||
|
macro_bias: st.SearchStrategy[float] | None = None,
|
||||||
|
valuation_score: st.SearchStrategy[float | None] | None = None,
|
||||||
|
earnings_proximity_days: st.SearchStrategy[int | None] | None = None,
|
||||||
|
) -> st.SearchStrategy[NormalizedInput]:
|
||||||
|
"""Build a NormalizedInput strategy with optional field overrides.
|
||||||
|
|
||||||
|
Fields not overridden are generated with full arbitrary ranges so that
|
||||||
|
the property tests prove the filter holds *regardless* of other values.
|
||||||
|
"""
|
||||||
|
return st.builds(
|
||||||
|
NormalizedInput,
|
||||||
|
ticker=_ticker,
|
||||||
|
evaluated_at=_aware_datetime,
|
||||||
|
bars=_bars_strategy,
|
||||||
|
valuation_score=(
|
||||||
|
valuation_score
|
||||||
|
if valuation_score is not None
|
||||||
|
else st.one_of(st.none(), _unit_float)
|
||||||
|
),
|
||||||
|
earnings_proximity_days=(
|
||||||
|
earnings_proximity_days
|
||||||
|
if earnings_proximity_days is not None
|
||||||
|
else st.one_of(st.none(), st.integers(min_value=0, max_value=365))
|
||||||
|
),
|
||||||
|
macro_bias=(
|
||||||
|
macro_bias
|
||||||
|
if macro_bias is not None
|
||||||
|
else st.floats(min_value=-1.0, max_value=1.0, allow_nan=False)
|
||||||
|
),
|
||||||
|
open_positions=st.lists(_open_position, min_size=0, max_size=2),
|
||||||
|
closing_prices=st.lists(_positive_float, min_size=0, max_size=5),
|
||||||
|
returns=st.lists(_finite_float, min_size=0, max_size=5),
|
||||||
|
current_price=st.one_of(st.none(), _positive_float),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Default config matches the production defaults
|
||||||
|
_default_config = HardFilterConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@given(normalized=_normalized_input_strategy(macro_bias=st.just(-1.0)))
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_macro_bias_negative_always_filters(normalized: NormalizedInput) -> None:
|
||||||
|
"""**Validates: Requirements 4.1, 17.7**
|
||||||
|
|
||||||
|
For any NormalizedInput where macro_bias == -1.0, the hard filter
|
||||||
|
SHALL always produce filtered=True with "macro_bias_negative" in
|
||||||
|
reasons, regardless of all other field values.
|
||||||
|
"""
|
||||||
|
result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)
|
||||||
|
|
||||||
|
assert result.filtered is True, (
|
||||||
|
f"Expected filtered=True for macro_bias=-1.0 but got filtered=False.\n"
|
||||||
|
f" ticker={normalized.ticker}, valuation_score={normalized.valuation_score}, "
|
||||||
|
f"earnings_proximity_days={normalized.earnings_proximity_days}"
|
||||||
|
)
|
||||||
|
assert "macro_bias_negative" in result.reasons, (
|
||||||
|
f"Expected 'macro_bias_negative' in reasons but got {result.reasons}.\n"
|
||||||
|
f" ticker={normalized.ticker}, macro_bias={normalized.macro_bias}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@given(
|
||||||
|
normalized=_normalized_input_strategy(
|
||||||
|
valuation_score=st.floats(
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=0.3,
|
||||||
|
exclude_max=True,
|
||||||
|
allow_nan=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_valuation_below_threshold_always_filters(normalized: NormalizedInput) -> None:
|
||||||
|
"""**Validates: Requirements 4.2, 17.7**
|
||||||
|
|
||||||
|
For any NormalizedInput where valuation_score is not None and < 0.3,
|
||||||
|
the hard filter SHALL always produce filtered=True with
|
||||||
|
"valuation_below_threshold" in reasons, regardless of all other
|
||||||
|
field values.
|
||||||
|
"""
|
||||||
|
result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)
|
||||||
|
|
||||||
|
assert result.filtered is True, (
|
||||||
|
f"Expected filtered=True for valuation_score={normalized.valuation_score} "
|
||||||
|
f"but got filtered=False.\n"
|
||||||
|
f" ticker={normalized.ticker}, macro_bias={normalized.macro_bias}, "
|
||||||
|
f"earnings_proximity_days={normalized.earnings_proximity_days}"
|
||||||
|
)
|
||||||
|
assert "valuation_below_threshold" in result.reasons, (
|
||||||
|
f"Expected 'valuation_below_threshold' in reasons but got {result.reasons}.\n"
|
||||||
|
f" ticker={normalized.ticker}, valuation_score={normalized.valuation_score}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@given(
|
||||||
|
normalized=_normalized_input_strategy(
|
||||||
|
earnings_proximity_days=st.integers(min_value=0, max_value=5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_earnings_proximity_always_filters(normalized: NormalizedInput) -> None:
|
||||||
|
"""**Validates: Requirements 4.3, 17.7**
|
||||||
|
|
||||||
|
For any NormalizedInput where earnings_proximity_days is not None
|
||||||
|
and <= 5, the hard filter SHALL always produce filtered=True with
|
||||||
|
"earnings_block" in reasons, regardless of all other field values.
|
||||||
|
"""
|
||||||
|
result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)
|
||||||
|
|
||||||
|
assert result.filtered is True, (
|
||||||
|
f"Expected filtered=True for earnings_proximity_days="
|
||||||
|
f"{normalized.earnings_proximity_days} but got filtered=False.\n"
|
||||||
|
f" ticker={normalized.ticker}, macro_bias={normalized.macro_bias}, "
|
||||||
|
f"valuation_score={normalized.valuation_score}"
|
||||||
|
)
|
||||||
|
assert "earnings_block" in result.reasons, (
|
||||||
|
f"Expected 'earnings_block' in reasons but got {result.reasons}.\n"
|
||||||
|
f" ticker={normalized.ticker}, "
|
||||||
|
f"earnings_proximity_days={normalized.earnings_proximity_days}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Feature: dual-pipeline-signal-engine, Property: SignalOutput round-trip serialization
|
||||||
|
"""Property-based tests for SignalOutput round-trip serialization.
|
||||||
|
|
||||||
|
Feature: dual-pipeline-signal-engine
|
||||||
|
|
||||||
|
Tests the SignalOutput round-trip serialization property from the design
|
||||||
|
specification: for any valid SignalOutput instance, serializing to JSON via
|
||||||
|
model_dump_json() and deserializing back via model_validate_json() SHALL
|
||||||
|
produce a SignalOutput object equivalent to the original.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from hypothesis import given, settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
ExitSignal,
|
||||||
|
ExitType,
|
||||||
|
SignalOutput,
|
||||||
|
TradePlan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property: SignalOutput Round-Trip Serialization
|
||||||
|
# Validates: Requirements 10.5, 17.6
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hypothesis strategies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_finite_float = st.floats(allow_nan=False, allow_infinity=False)
|
||||||
|
_non_negative_finite_float = st.floats(
|
||||||
|
min_value=0.0, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
_unit_float = st.floats(
|
||||||
|
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_aware_datetime_strategy = st.datetimes(
|
||||||
|
min_value=datetime(2020, 1, 1),
|
||||||
|
max_value=datetime(2030, 12, 31),
|
||||||
|
timezones=st.just(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
_ticker_strategy = st.text(
|
||||||
|
alphabet=st.characters(whitelist_categories=("Lu",)),
|
||||||
|
min_size=1,
|
||||||
|
max_size=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
_verdict_strategy = st.sampled_from(["BUY", "WATCH", "SKIP"])
|
||||||
|
|
||||||
|
_pipeline_mode_strategy = st.sampled_from(["dual_pipeline", "heuristic_only", "probabilistic_only"])
|
||||||
|
|
||||||
|
# --- TradePlan strategy ---
|
||||||
|
|
||||||
|
_trade_plan_strategy = st.builds(
|
||||||
|
TradePlan,
|
||||||
|
entry_price=_finite_float,
|
||||||
|
stop_loss=_finite_float,
|
||||||
|
target_1=_finite_float,
|
||||||
|
target_2=_finite_float,
|
||||||
|
position_size_pct=_unit_float,
|
||||||
|
max_loss_pct=_unit_float,
|
||||||
|
dual_confirmed=st.booleans(),
|
||||||
|
probabilistic_only=st.booleans(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- ExitSignal strategy ---
|
||||||
|
|
||||||
|
_exit_signal_strategy = st.builds(
|
||||||
|
ExitSignal,
|
||||||
|
position_id=st.uuids().map(str),
|
||||||
|
ticker=_ticker_strategy,
|
||||||
|
exit_type=st.sampled_from(list(ExitType)),
|
||||||
|
reason=st.sampled_from(["stop_hit", "target_1_hit", "target_2_hit", "trailing_stop_hit"]),
|
||||||
|
price=_finite_float,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Simple dict strategies for detail payloads ---
|
||||||
|
|
||||||
|
_simple_detail_strategy = st.fixed_dictionaries(
|
||||||
|
{},
|
||||||
|
optional={
|
||||||
|
"score": _finite_float,
|
||||||
|
"label": st.text(max_size=20),
|
||||||
|
"count": st.integers(min_value=0, max_value=1000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- SignalOutput strategy ---
|
||||||
|
|
||||||
|
_signal_output_strategy = st.builds(
|
||||||
|
SignalOutput,
|
||||||
|
output_id=st.uuids().map(str),
|
||||||
|
ticker=_ticker_strategy,
|
||||||
|
timestamp=_aware_datetime_strategy,
|
||||||
|
price=_finite_float,
|
||||||
|
heuristic_verdict=_verdict_strategy,
|
||||||
|
heuristic_confidence=_unit_float,
|
||||||
|
heuristic_s_total=_finite_float,
|
||||||
|
probabilistic_verdict=_verdict_strategy,
|
||||||
|
probabilistic_p_up=_unit_float,
|
||||||
|
probabilistic_entropy=_unit_float,
|
||||||
|
probabilistic_ev_r=_finite_float,
|
||||||
|
delta_agreement=st.booleans(),
|
||||||
|
delta_confidence_delta=_non_negative_finite_float,
|
||||||
|
delta_reasons=st.lists(st.text(min_size=1, max_size=50), min_size=0, max_size=5),
|
||||||
|
trade_plan=st.one_of(st.none(), _trade_plan_strategy),
|
||||||
|
exit_signals=st.lists(_exit_signal_strategy, min_size=0, max_size=3),
|
||||||
|
heuristic_detail=_simple_detail_strategy,
|
||||||
|
probabilistic_detail=_simple_detail_strategy,
|
||||||
|
pipeline_mode=_pipeline_mode_strategy,
|
||||||
|
shadow_mode=st.booleans(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@given(output=_signal_output_strategy)
|
||||||
|
@settings(max_examples=100)
|
||||||
|
def test_signal_output_round_trip_serialization(output: SignalOutput) -> None:
|
||||||
|
"""**Validates: Requirements 10.5, 17.6**
|
||||||
|
|
||||||
|
For any valid SignalOutput instance, serializing to JSON and then
|
||||||
|
deserializing back SHALL produce a SignalOutput object equivalent
|
||||||
|
to the original.
|
||||||
|
"""
|
||||||
|
json_str = output.model_dump_json()
|
||||||
|
restored = SignalOutput.model_validate_json(json_str)
|
||||||
|
assert restored == output, (
|
||||||
|
f"Round-trip failed: deserialized SignalOutput differs from original.\n"
|
||||||
|
f" ticker: {output.ticker}\n"
|
||||||
|
f" heuristic_verdict: {output.heuristic_verdict}\n"
|
||||||
|
f" probabilistic_verdict: {output.probabilistic_verdict}\n"
|
||||||
|
f" trade_plan present: {output.trade_plan is not None}\n"
|
||||||
|
f" exit_signals count: {len(output.exit_signals)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Unit tests for services.signal_engine.signals.base helper functions.
|
||||||
|
|
||||||
|
Tests swing high/low detection, lookback validation, and SMA computation.
|
||||||
|
Requirements: 2.6, 2.7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar
|
||||||
|
from services.signal_engine.signals.base import (
|
||||||
|
compute_sma,
|
||||||
|
find_swing_high,
|
||||||
|
find_swing_low,
|
||||||
|
validate_lookback,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(
|
||||||
|
close: float,
|
||||||
|
high: float | None = None,
|
||||||
|
low: float | None = None,
|
||||||
|
ts_offset: int = 0,
|
||||||
|
) -> OHLCVBar:
|
||||||
|
"""Create a minimal OHLCVBar for testing."""
|
||||||
|
return OHLCVBar(
|
||||||
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
open=close,
|
||||||
|
high=high if high is not None else close,
|
||||||
|
low=low if low is not None else close,
|
||||||
|
close=close,
|
||||||
|
volume=1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_swing_high
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_high_basic() -> None:
|
||||||
|
bars = [_bar(10, high=12), _bar(10, high=15), _bar(10, high=11)]
|
||||||
|
result = find_swing_high(bars, lookback=3)
|
||||||
|
assert result is not None
|
||||||
|
idx, price = result
|
||||||
|
assert idx == 1
|
||||||
|
assert price == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_high_lookback_subset() -> None:
|
||||||
|
bars = [_bar(10, high=20), _bar(10, high=12), _bar(10, high=15), _bar(10, high=11)]
|
||||||
|
# lookback=2 → only last 2 bars (index 2 and 3 in original)
|
||||||
|
result = find_swing_high(bars, lookback=2)
|
||||||
|
assert result is not None
|
||||||
|
idx, price = result
|
||||||
|
assert idx == 2 # bar at index 2 has high=15
|
||||||
|
assert price == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_high_insufficient_data() -> None:
|
||||||
|
bars = [_bar(10, high=12)]
|
||||||
|
assert find_swing_high(bars, lookback=5) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_high_zero_lookback() -> None:
|
||||||
|
bars = [_bar(10, high=12)]
|
||||||
|
assert find_swing_high(bars, lookback=0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_high_negative_lookback() -> None:
|
||||||
|
bars = [_bar(10, high=12)]
|
||||||
|
assert find_swing_high(bars, lookback=-1) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_high_tie_takes_last() -> None:
|
||||||
|
"""When multiple bars share the same high, the last one wins (>=)."""
|
||||||
|
bars = [_bar(10, high=15), _bar(10, high=15), _bar(10, high=10)]
|
||||||
|
result = find_swing_high(bars, lookback=3)
|
||||||
|
assert result is not None
|
||||||
|
idx, price = result
|
||||||
|
assert idx == 1
|
||||||
|
assert price == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_swing_low
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_low_basic() -> None:
|
||||||
|
bars = [_bar(10, low=8), _bar(10, low=5), _bar(10, low=9)]
|
||||||
|
result = find_swing_low(bars, lookback=3)
|
||||||
|
assert result is not None
|
||||||
|
idx, price = result
|
||||||
|
assert idx == 1
|
||||||
|
assert price == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_low_lookback_subset() -> None:
|
||||||
|
bars = [_bar(10, low=2), _bar(10, low=8), _bar(10, low=5), _bar(10, low=9)]
|
||||||
|
# lookback=2 → only last 2 bars (index 2 and 3)
|
||||||
|
result = find_swing_low(bars, lookback=2)
|
||||||
|
assert result is not None
|
||||||
|
idx, price = result
|
||||||
|
assert idx == 2 # bar at index 2 has low=5
|
||||||
|
assert price == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_low_insufficient_data() -> None:
|
||||||
|
bars = [_bar(10, low=8)]
|
||||||
|
assert find_swing_low(bars, lookback=5) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_low_zero_lookback() -> None:
|
||||||
|
bars = [_bar(10, low=8)]
|
||||||
|
assert find_swing_low(bars, lookback=0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_swing_low_tie_takes_last() -> None:
|
||||||
|
"""When multiple bars share the same low, the last one wins (<=)."""
|
||||||
|
bars = [_bar(10, low=5), _bar(10, low=5), _bar(10, low=10)]
|
||||||
|
result = find_swing_low(bars, lookback=3)
|
||||||
|
assert result is not None
|
||||||
|
idx, price = result
|
||||||
|
assert idx == 1
|
||||||
|
assert price == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# validate_lookback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lookback_sufficient() -> None:
|
||||||
|
bars = [_bar(10)] * 20
|
||||||
|
assert validate_lookback(bars, min_bars=20) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lookback_more_than_enough() -> None:
|
||||||
|
bars = [_bar(10)] * 50
|
||||||
|
assert validate_lookback(bars, min_bars=20) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lookback_insufficient() -> None:
|
||||||
|
bars = [_bar(10)] * 5
|
||||||
|
assert validate_lookback(bars, min_bars=20) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lookback_empty() -> None:
|
||||||
|
assert validate_lookback([], min_bars=1) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lookback_zero_min() -> None:
|
||||||
|
assert validate_lookback([], min_bars=0) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# compute_sma
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_sma_basic() -> None:
|
||||||
|
bars = [_bar(10), _bar(20), _bar(30)]
|
||||||
|
result = compute_sma(bars, period=3)
|
||||||
|
assert result is not None
|
||||||
|
assert result == 20.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_sma_subset() -> None:
|
||||||
|
bars = [_bar(100), _bar(10), _bar(20), _bar(30)]
|
||||||
|
# period=3 → average of last 3 bars: (10+20+30)/3 = 20
|
||||||
|
result = compute_sma(bars, period=3)
|
||||||
|
assert result is not None
|
||||||
|
assert result == 20.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_sma_single_bar() -> None:
|
||||||
|
bars = [_bar(42)]
|
||||||
|
result = compute_sma(bars, period=1)
|
||||||
|
assert result is not None
|
||||||
|
assert result == 42.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_sma_insufficient_data() -> None:
|
||||||
|
bars = [_bar(10), _bar(20)]
|
||||||
|
assert compute_sma(bars, period=5) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_sma_zero_period() -> None:
|
||||||
|
bars = [_bar(10)]
|
||||||
|
assert compute_sma(bars, period=0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_sma_negative_period() -> None:
|
||||||
|
bars = [_bar(10)]
|
||||||
|
assert compute_sma(bars, period=-1) is None
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Unit tests for services.signal_engine.config.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Default values and fail-safe behaviour
|
||||||
|
- DB row parsing and application
|
||||||
|
- Environment variable overrides
|
||||||
|
- Sub-config derivation properties
|
||||||
|
- load_config() with mocked asyncpg pool
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.signal_engine.config import (
|
||||||
|
ExitConfig,
|
||||||
|
HardFilterConfig,
|
||||||
|
HeuristicConfig,
|
||||||
|
ProbabilisticConfig,
|
||||||
|
SignalEngineConfig,
|
||||||
|
_apply_db_rows,
|
||||||
|
_apply_env_overrides,
|
||||||
|
_parse_value,
|
||||||
|
load_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
"""SignalEngineConfig defaults match the design spec."""
|
||||||
|
|
||||||
|
def test_dual_pipeline_disabled_by_default(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
assert cfg.dual_pipeline_enabled is False
|
||||||
|
|
||||||
|
def test_both_pipelines_enabled_by_default(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
assert cfg.heuristic_pipeline_enabled is True
|
||||||
|
assert cfg.probabilistic_pipeline_enabled is True
|
||||||
|
|
||||||
|
def test_shadow_mode_off_by_default(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
assert cfg.shadow_mode is False
|
||||||
|
|
||||||
|
def test_timeframe_weights_default(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
expected = {
|
||||||
|
"M30": 0.03,
|
||||||
|
"H1": 0.07,
|
||||||
|
"H4": 0.15,
|
||||||
|
"D": 0.30,
|
||||||
|
"W": 0.30,
|
||||||
|
"M": 0.15,
|
||||||
|
}
|
||||||
|
assert cfg.timeframe_weights == expected
|
||||||
|
|
||||||
|
def test_hard_filter_defaults(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
assert cfg.hard_filter_valuation_min == 0.3
|
||||||
|
assert cfg.hard_filter_earnings_days == 5
|
||||||
|
assert cfg.hard_filter_macro_bias_skip == -1.0
|
||||||
|
|
||||||
|
def test_heuristic_threshold_defaults(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
assert cfg.heuristic_buy_confidence == 0.70
|
||||||
|
assert cfg.heuristic_buy_s_total == 1.2
|
||||||
|
assert cfg.heuristic_buy_valuation_min == 0.5
|
||||||
|
assert cfg.heuristic_watch_confidence == 0.55
|
||||||
|
|
||||||
|
def test_probabilistic_threshold_defaults(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
assert cfg.prob_buy_p_up == 0.60
|
||||||
|
assert cfg.prob_buy_entropy_max == 0.90
|
||||||
|
assert cfg.prob_buy_ev_r_min == 1.5
|
||||||
|
assert cfg.prob_buy_valuation_min == 0.5
|
||||||
|
assert cfg.prob_watch_p_up == 0.55
|
||||||
|
assert cfg.prob_watch_entropy_max == 0.95
|
||||||
|
assert cfg.prob_entropy_skip == 0.95
|
||||||
|
|
||||||
|
def test_regime_prior_defaults(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
assert cfg.regime_prior_bull == 0.58
|
||||||
|
assert cfg.regime_prior_range == 0.50
|
||||||
|
assert cfg.regime_prior_bear == 0.42
|
||||||
|
|
||||||
|
def test_exit_and_polling_defaults(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
assert cfg.trailing_stop_atr_multiplier == 2.0
|
||||||
|
assert cfg.polling_interval_seconds == 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sub-config derivation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubConfigs:
|
||||||
|
"""Properties derive correct sub-config instances."""
|
||||||
|
|
||||||
|
def test_hard_filter_config(self):
|
||||||
|
cfg = SignalEngineConfig(
|
||||||
|
hard_filter_valuation_min=0.4,
|
||||||
|
hard_filter_earnings_days=7,
|
||||||
|
hard_filter_macro_bias_skip=-0.5,
|
||||||
|
)
|
||||||
|
hf = cfg.hard_filter_config
|
||||||
|
assert isinstance(hf, HardFilterConfig)
|
||||||
|
assert hf.valuation_min == 0.4
|
||||||
|
assert hf.earnings_days == 7
|
||||||
|
assert hf.macro_bias_skip == -0.5
|
||||||
|
|
||||||
|
def test_heuristic_config(self):
|
||||||
|
cfg = SignalEngineConfig(
|
||||||
|
heuristic_buy_confidence=0.80,
|
||||||
|
heuristic_buy_s_total=1.5,
|
||||||
|
heuristic_buy_valuation_min=0.6,
|
||||||
|
heuristic_watch_confidence=0.60,
|
||||||
|
hard_filter_earnings_days=10,
|
||||||
|
)
|
||||||
|
hc = cfg.heuristic_config
|
||||||
|
assert isinstance(hc, HeuristicConfig)
|
||||||
|
assert hc.buy_confidence == 0.80
|
||||||
|
assert hc.buy_s_total == 1.5
|
||||||
|
assert hc.buy_valuation_min == 0.6
|
||||||
|
assert hc.watch_confidence == 0.60
|
||||||
|
assert hc.macro_bias_threshold == 0.0
|
||||||
|
assert hc.earnings_days_threshold == 10
|
||||||
|
|
||||||
|
def test_probabilistic_config(self):
|
||||||
|
cfg = SignalEngineConfig(
|
||||||
|
prob_buy_p_up=0.65,
|
||||||
|
regime_prior_bull=0.60,
|
||||||
|
)
|
||||||
|
pc = cfg.probabilistic_config
|
||||||
|
assert isinstance(pc, ProbabilisticConfig)
|
||||||
|
assert pc.buy_p_up == 0.65
|
||||||
|
assert pc.regime_prior_bull == 0.60
|
||||||
|
assert pc.macro_bias_threshold == 0.0
|
||||||
|
|
||||||
|
def test_exit_config(self):
|
||||||
|
cfg = SignalEngineConfig(trailing_stop_atr_multiplier=3.0)
|
||||||
|
ec = cfg.exit_config
|
||||||
|
assert isinstance(ec, ExitConfig)
|
||||||
|
assert ec.trailing_stop_atr_multiplier == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parse_value
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseValue:
|
||||||
|
def test_bool_true_variants(self):
|
||||||
|
for v in ("true", "True", "TRUE", "1", "yes"):
|
||||||
|
assert _parse_value(v, bool) is True
|
||||||
|
|
||||||
|
def test_bool_false_variants(self):
|
||||||
|
for v in ("false", "False", "0", "no", "anything"):
|
||||||
|
assert _parse_value(v, bool) is False
|
||||||
|
|
||||||
|
def test_int(self):
|
||||||
|
assert _parse_value("42", int) == 42
|
||||||
|
|
||||||
|
def test_float(self):
|
||||||
|
assert _parse_value("0.75", float) == 0.75
|
||||||
|
|
||||||
|
def test_dict_json(self):
|
||||||
|
raw = json.dumps({"D": 0.30, "W": 0.30})
|
||||||
|
result = _parse_value(raw, dict)
|
||||||
|
assert result == {"D": 0.30, "W": 0.30}
|
||||||
|
|
||||||
|
def test_invalid_int_raises(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_parse_value("not_a_number", int)
|
||||||
|
|
||||||
|
def test_invalid_json_raises(self):
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
_parse_value("{bad json", dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _apply_db_rows
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyDbRows:
|
||||||
|
def test_applies_known_keys(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
rows = [
|
||||||
|
("signal_engine_dual_pipeline_enabled", "true"),
|
||||||
|
("signal_engine_prob_buy_p_up", "0.65"),
|
||||||
|
("signal_engine_polling_interval_seconds", "60"),
|
||||||
|
]
|
||||||
|
_apply_db_rows(cfg, rows)
|
||||||
|
assert cfg.dual_pipeline_enabled is True
|
||||||
|
assert cfg.prob_buy_p_up == 0.65
|
||||||
|
assert cfg.polling_interval_seconds == 60
|
||||||
|
|
||||||
|
def test_ignores_unknown_keys(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
rows = [("signal_engine_unknown_field", "whatever")]
|
||||||
|
_apply_db_rows(cfg, rows) # should not raise
|
||||||
|
|
||||||
|
def test_invalid_value_keeps_default(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
rows = [("signal_engine_hard_filter_earnings_days", "not_a_number")]
|
||||||
|
_apply_db_rows(cfg, rows)
|
||||||
|
assert cfg.hard_filter_earnings_days == 5 # default preserved
|
||||||
|
|
||||||
|
def test_timeframe_weights_from_json(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
new_weights = {"D": 0.50, "W": 0.50}
|
||||||
|
rows = [
|
||||||
|
("signal_engine_timeframe_weights", json.dumps(new_weights)),
|
||||||
|
]
|
||||||
|
_apply_db_rows(cfg, rows)
|
||||||
|
assert cfg.timeframe_weights == new_weights
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _apply_env_overrides
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyEnvOverrides:
|
||||||
|
def test_env_override_bool(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
with patch.dict(os.environ, {"SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED": "true"}):
|
||||||
|
_apply_env_overrides(cfg)
|
||||||
|
assert cfg.dual_pipeline_enabled is True
|
||||||
|
|
||||||
|
def test_env_override_float(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
with patch.dict(os.environ, {"SIGNAL_ENGINE_PROB_BUY_P_UP": "0.70"}):
|
||||||
|
_apply_env_overrides(cfg)
|
||||||
|
assert cfg.prob_buy_p_up == 0.70
|
||||||
|
|
||||||
|
def test_env_override_int(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
with patch.dict(os.environ, {"SIGNAL_ENGINE_POLLING_INTERVAL_SECONDS": "120"}):
|
||||||
|
_apply_env_overrides(cfg)
|
||||||
|
assert cfg.polling_interval_seconds == 120
|
||||||
|
|
||||||
|
def test_env_ignores_unrelated_vars(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
with patch.dict(os.environ, {"UNRELATED_VAR": "hello"}):
|
||||||
|
_apply_env_overrides(cfg)
|
||||||
|
# No change — just verifying no crash
|
||||||
|
assert cfg.dual_pipeline_enabled is False
|
||||||
|
|
||||||
|
def test_invalid_env_value_keeps_previous(self):
|
||||||
|
cfg = SignalEngineConfig()
|
||||||
|
cfg.hard_filter_earnings_days = 10
|
||||||
|
with patch.dict(os.environ, {"SIGNAL_ENGINE_HARD_FILTER_EARNINGS_DAYS": "bad"}):
|
||||||
|
_apply_env_overrides(cfg)
|
||||||
|
assert cfg.hard_filter_earnings_days == 10 # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_config (async)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadConfig:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_with_db_rows(self):
|
||||||
|
"""DB rows are applied over defaults."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
pool.fetch = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{"key": "signal_engine_dual_pipeline_enabled", "value": "true"},
|
||||||
|
{"key": "signal_engine_shadow_mode", "value": "true"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cfg = await load_config(pool)
|
||||||
|
assert cfg.dual_pipeline_enabled is True
|
||||||
|
assert cfg.shadow_mode is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_with_empty_db(self):
|
||||||
|
"""Empty DB result returns safe defaults."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
pool.fetch = AsyncMock(return_value=[])
|
||||||
|
cfg = await load_config(pool)
|
||||||
|
assert cfg.dual_pipeline_enabled is False
|
||||||
|
assert cfg.heuristic_pipeline_enabled is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_db_failure_failsafe(self):
|
||||||
|
"""DB error falls back to disabled (fail-safe)."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
pool.fetch = AsyncMock(side_effect=Exception("connection refused"))
|
||||||
|
cfg = await load_config(pool)
|
||||||
|
assert cfg.dual_pipeline_enabled is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_env_overrides_db_values(self):
|
||||||
|
"""Environment variables take precedence over DB values."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
pool.fetch = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{"key": "signal_engine_prob_buy_p_up", "value": "0.55"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, {"SIGNAL_ENGINE_PROB_BUY_P_UP": "0.70"}):
|
||||||
|
cfg = await load_config(pool)
|
||||||
|
assert cfg.prob_buy_p_up == 0.70 # env wins
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_env_overrides_applied_after_db_failure(self):
|
||||||
|
"""Env overrides still apply even when DB read fails."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
pool.fetch = AsyncMock(side_effect=Exception("timeout"))
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED": "true"}
|
||||||
|
):
|
||||||
|
cfg = await load_config(pool)
|
||||||
|
# Env override can re-enable even after DB failure
|
||||||
|
assert cfg.dual_pipeline_enabled is True
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""Unit tests for the multi-timeframe confluence engine.
|
||||||
|
|
||||||
|
Validates compute_confluence against requirements 3.1–3.6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from services.signal_engine.confluence import (
|
||||||
|
HIGHER_TIMEFRAME_ANCHORS,
|
||||||
|
MIN_TIMEFRAME_COUNT,
|
||||||
|
compute_confluence,
|
||||||
|
)
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
SignalDirection,
|
||||||
|
SignalResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default timeframe weights from the design (Requirement 3.1)
|
||||||
|
DEFAULT_WEIGHTS: dict[str, float] = {
|
||||||
|
"M30": 0.03,
|
||||||
|
"H1": 0.07,
|
||||||
|
"H4": 0.15,
|
||||||
|
"D": 0.30,
|
||||||
|
"W": 0.30,
|
||||||
|
"M": 0.15,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_signal(
|
||||||
|
signal_type: str = "fibonacci",
|
||||||
|
timeframe: str = "D",
|
||||||
|
strength: float = 0.8,
|
||||||
|
direction: SignalDirection = SignalDirection.BULLISH,
|
||||||
|
confidence: float = 0.9,
|
||||||
|
) -> SignalResult:
|
||||||
|
"""Build a minimal SignalResult with sensible defaults."""
|
||||||
|
return SignalResult(
|
||||||
|
signal_type=signal_type,
|
||||||
|
timeframe=timeframe,
|
||||||
|
strength=strength,
|
||||||
|
direction=direction,
|
||||||
|
confidence=confidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMinimumConfluenceThreshold:
|
||||||
|
"""Requirement 3.3: signals triggering on < 2 timeframes are discarded."""
|
||||||
|
|
||||||
|
def test_single_timeframe_discarded(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_zero_timeframes_discarded(self):
|
||||||
|
signal_results = {"fibonacci": {}}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_two_timeframes_passes_minimum(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D"),
|
||||||
|
"W": _make_signal(timeframe="W"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].signal_type == "fibonacci"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHigherTimeframeAnchor:
|
||||||
|
"""Requirement 3.4: signals without at least one of D, W, M are discarded."""
|
||||||
|
|
||||||
|
def test_only_intraday_timeframes_discarded(self):
|
||||||
|
"""M30 + H1 = 2 timeframes but no D/W/M anchor → discarded."""
|
||||||
|
signal_results = {
|
||||||
|
"rsi": {
|
||||||
|
"M30": _make_signal(timeframe="M30"),
|
||||||
|
"H1": _make_signal(timeframe="H1"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_intraday_plus_h4_discarded(self):
|
||||||
|
"""M30 + H1 + H4 = 3 timeframes but no D/W/M → discarded."""
|
||||||
|
signal_results = {
|
||||||
|
"rsi": {
|
||||||
|
"M30": _make_signal(timeframe="M30"),
|
||||||
|
"H1": _make_signal(timeframe="H1"),
|
||||||
|
"H4": _make_signal(timeframe="H4"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_with_daily_anchor_passes(self):
|
||||||
|
signal_results = {
|
||||||
|
"rsi": {
|
||||||
|
"H4": _make_signal(timeframe="H4"),
|
||||||
|
"D": _make_signal(timeframe="D"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_with_weekly_anchor_passes(self):
|
||||||
|
signal_results = {
|
||||||
|
"rsi": {
|
||||||
|
"H1": _make_signal(timeframe="H1"),
|
||||||
|
"W": _make_signal(timeframe="W"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_with_monthly_anchor_passes(self):
|
||||||
|
signal_results = {
|
||||||
|
"rsi": {
|
||||||
|
"H4": _make_signal(timeframe="H4"),
|
||||||
|
"M": _make_signal(timeframe="M"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfluenceScoreComputation:
|
||||||
|
"""Requirement 3.2: C_confluence = Σ(w_tf · s_tf)."""
|
||||||
|
|
||||||
|
def test_two_timeframes_score(self):
|
||||||
|
"""D(0.30) * 0.8 + W(0.30) * 0.6 = 0.24 + 0.18 = 0.42."""
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.8),
|
||||||
|
"W": _make_signal(timeframe="W", strength=0.6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert abs(result[0].confluence_score - 0.42) < 1e-9
|
||||||
|
|
||||||
|
def test_all_timeframes_score(self):
|
||||||
|
"""All six timeframes with strength 1.0 → sum of all weights."""
|
||||||
|
signal_results = {
|
||||||
|
"ma_stack": {
|
||||||
|
tf: _make_signal(timeframe=tf, strength=1.0)
|
||||||
|
for tf in DEFAULT_WEIGHTS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
expected = sum(DEFAULT_WEIGHTS.values())
|
||||||
|
assert abs(result[0].confluence_score - expected) < 1e-9
|
||||||
|
|
||||||
|
def test_zero_strength_contributes_zero(self):
|
||||||
|
"""D(0.30) * 0.0 + W(0.30) * 1.0 = 0.0 + 0.30 = 0.30."""
|
||||||
|
signal_results = {
|
||||||
|
"rsi": {
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.0),
|
||||||
|
"W": _make_signal(timeframe="W", strength=1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert abs(result[0].confluence_score - 0.30) < 1e-9
|
||||||
|
|
||||||
|
def test_unknown_timeframe_weight_defaults_to_zero(self):
|
||||||
|
"""A timeframe not in the weights dict contributes 0 to the score."""
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.5),
|
||||||
|
"UNKNOWN": _make_signal(timeframe="UNKNOWN", strength=1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# UNKNOWN is not in DEFAULT_WEIGHTS, so its weight is 0.0
|
||||||
|
# But we still need a D/W/M anchor and >= 2 timeframes
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert abs(result[0].confluence_score - 0.15) < 1e-9 # 0.30 * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerTimeframeStrengths:
|
||||||
|
"""Verify per_timeframe dict contains correct strength values."""
|
||||||
|
|
||||||
|
def test_per_timeframe_populated(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.7),
|
||||||
|
"W": _make_signal(timeframe="W", strength=0.9),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].per_timeframe == {"D": 0.7, "W": 0.9}
|
||||||
|
|
||||||
|
def test_active_timeframes_match_per_timeframe_keys(self):
|
||||||
|
signal_results = {
|
||||||
|
"ma_stack": {
|
||||||
|
"H4": _make_signal(timeframe="H4", strength=0.5),
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.6),
|
||||||
|
"W": _make_signal(timeframe="W", strength=0.8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result[0].active_timeframes) == set(result[0].per_timeframe.keys())
|
||||||
|
|
||||||
|
|
||||||
|
class TestDominantDirection:
|
||||||
|
"""Verify direction is determined by majority vote across timeframes."""
|
||||||
|
|
||||||
|
def test_all_bullish(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(direction=SignalDirection.BULLISH),
|
||||||
|
"W": _make_signal(direction=SignalDirection.BULLISH),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result[0].direction == SignalDirection.BULLISH
|
||||||
|
|
||||||
|
def test_all_bearish(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(direction=SignalDirection.BEARISH),
|
||||||
|
"W": _make_signal(direction=SignalDirection.BEARISH),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result[0].direction == SignalDirection.BEARISH
|
||||||
|
|
||||||
|
def test_majority_bullish(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(direction=SignalDirection.BULLISH),
|
||||||
|
"W": _make_signal(direction=SignalDirection.BULLISH),
|
||||||
|
"M": _make_signal(direction=SignalDirection.BEARISH),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result[0].direction == SignalDirection.BULLISH
|
||||||
|
|
||||||
|
def test_tie_resolves_to_neutral(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(direction=SignalDirection.BULLISH),
|
||||||
|
"W": _make_signal(direction=SignalDirection.BEARISH),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result[0].direction == SignalDirection.NEUTRAL
|
||||||
|
|
||||||
|
def test_neutral_votes_do_not_count(self):
|
||||||
|
"""2 bullish + 1 neutral → bullish wins."""
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(direction=SignalDirection.BULLISH),
|
||||||
|
"W": _make_signal(direction=SignalDirection.BULLISH),
|
||||||
|
"M": _make_signal(direction=SignalDirection.NEUTRAL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert result[0].direction == SignalDirection.BULLISH
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleSignalTypes:
|
||||||
|
"""Verify that multiple signal types are processed independently."""
|
||||||
|
|
||||||
|
def test_two_signals_both_pass(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(signal_type="fibonacci", timeframe="D"),
|
||||||
|
"W": _make_signal(signal_type="fibonacci", timeframe="W"),
|
||||||
|
},
|
||||||
|
"rsi": {
|
||||||
|
"H4": _make_signal(signal_type="rsi", timeframe="H4"),
|
||||||
|
"D": _make_signal(signal_type="rsi", timeframe="D"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 2
|
||||||
|
types = {cs.signal_type for cs in result}
|
||||||
|
assert types == {"fibonacci", "rsi"}
|
||||||
|
|
||||||
|
def test_one_passes_one_discarded(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(signal_type="fibonacci", timeframe="D"),
|
||||||
|
"W": _make_signal(signal_type="fibonacci", timeframe="W"),
|
||||||
|
},
|
||||||
|
"rsi": {
|
||||||
|
# Only 1 timeframe → discarded
|
||||||
|
"D": _make_signal(signal_type="rsi", timeframe="D"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].signal_type == "fibonacci"
|
||||||
|
|
||||||
|
def test_one_passes_one_no_anchor(self):
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(signal_type="fibonacci", timeframe="D"),
|
||||||
|
"W": _make_signal(signal_type="fibonacci", timeframe="W"),
|
||||||
|
},
|
||||||
|
"rsi": {
|
||||||
|
# 2 timeframes but no D/W/M → discarded
|
||||||
|
"M30": _make_signal(signal_type="rsi", timeframe="M30"),
|
||||||
|
"H1": _make_signal(signal_type="rsi", timeframe="H1"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].signal_type == "fibonacci"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyInputs:
|
||||||
|
"""Edge cases with empty inputs."""
|
||||||
|
|
||||||
|
def test_empty_signal_results(self):
|
||||||
|
result = compute_confluence({}, DEFAULT_WEIGHTS)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_empty_weights(self):
|
||||||
|
"""Signals pass filters but all weights are 0 → score is 0.0."""
|
||||||
|
signal_results = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.8),
|
||||||
|
"W": _make_signal(timeframe="W", strength=0.6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = compute_confluence(signal_results, {})
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].confluence_score == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfluenceScoreMonotonicity:
|
||||||
|
"""Requirement 3.6: more timeframes with higher weights → higher score."""
|
||||||
|
|
||||||
|
def test_adding_timeframe_increases_score(self):
|
||||||
|
"""Adding a third timeframe with non-zero strength increases the score."""
|
||||||
|
two_tf = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.8),
|
||||||
|
"W": _make_signal(timeframe="W", strength=0.6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
three_tf = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.8),
|
||||||
|
"W": _make_signal(timeframe="W", strength=0.6),
|
||||||
|
"H4": _make_signal(timeframe="H4", strength=0.5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result_2 = compute_confluence(two_tf, DEFAULT_WEIGHTS)
|
||||||
|
result_3 = compute_confluence(three_tf, DEFAULT_WEIGHTS)
|
||||||
|
assert result_3[0].confluence_score > result_2[0].confluence_score
|
||||||
|
|
||||||
|
def test_higher_weight_timeframe_contributes_more(self):
|
||||||
|
"""D (weight 0.30) contributes more than M30 (weight 0.03) at same strength."""
|
||||||
|
with_d = {
|
||||||
|
"fibonacci": {
|
||||||
|
"D": _make_signal(timeframe="D", strength=0.5),
|
||||||
|
"W": _make_signal(timeframe="W", strength=0.5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with_m30 = {
|
||||||
|
"fibonacci": {
|
||||||
|
"M30": _make_signal(timeframe="M30", strength=0.5),
|
||||||
|
"W": _make_signal(timeframe="W", strength=0.5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result_d = compute_confluence(with_d, DEFAULT_WEIGHTS)
|
||||||
|
result_m30 = compute_confluence(with_m30, DEFAULT_WEIGHTS)
|
||||||
|
assert result_d[0].confluence_score > result_m30[0].confluence_score
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
"""Verify module-level constants match the design."""
|
||||||
|
|
||||||
|
def test_higher_timeframe_anchors(self):
|
||||||
|
assert HIGHER_TIMEFRAME_ANCHORS == frozenset({"D", "W", "M"})
|
||||||
|
|
||||||
|
def test_min_timeframe_count(self):
|
||||||
|
assert MIN_TIMEFRAME_COUNT == 2
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""Unit tests for services.signal_engine.correlation — Signal cluster classification and penalty.
|
||||||
|
|
||||||
|
Tests classify_signal mapping, apply_correlation_penalty decay logic,
|
||||||
|
cross-cluster independence, single-signal clusters, and edge cases.
|
||||||
|
|
||||||
|
Requirements: 7.1, 7.2, 7.3, 7.4
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from services.signal_engine.correlation import (
|
||||||
|
SignalCluster,
|
||||||
|
apply_correlation_penalty,
|
||||||
|
classify_signal,
|
||||||
|
)
|
||||||
|
from services.signal_engine.models import LikelihoodRatio
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _lr(
|
||||||
|
signal_type: str,
|
||||||
|
cluster: str,
|
||||||
|
log_lr: float,
|
||||||
|
*,
|
||||||
|
hit_rate: float = 0.6,
|
||||||
|
strength: float = 0.7,
|
||||||
|
) -> LikelihoodRatio:
|
||||||
|
"""Create a LikelihoodRatio with sensible defaults."""
|
||||||
|
return LikelihoodRatio(
|
||||||
|
signal_type=signal_type,
|
||||||
|
cluster=cluster,
|
||||||
|
lr=math.exp(log_lr),
|
||||||
|
log_lr=log_lr,
|
||||||
|
penalized_log_lr=log_lr, # pre-penalty: same as log_lr
|
||||||
|
hit_rate=hit_rate,
|
||||||
|
strength=strength,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. classify_signal — known signal types (Requirement 7.1)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifySignal:
|
||||||
|
"""Verify signal type → cluster mapping."""
|
||||||
|
|
||||||
|
def test_ma_stack_is_momentum(self) -> None:
|
||||||
|
assert classify_signal("ma_stack") == SignalCluster.MOMENTUM
|
||||||
|
|
||||||
|
def test_rsi_is_momentum(self) -> None:
|
||||||
|
assert classify_signal("rsi") == SignalCluster.MOMENTUM
|
||||||
|
|
||||||
|
def test_fibonacci_is_structure(self) -> None:
|
||||||
|
assert classify_signal("fibonacci") == SignalCluster.STRUCTURE
|
||||||
|
|
||||||
|
def test_elliott_wave_is_structure(self) -> None:
|
||||||
|
assert classify_signal("elliott_wave") == SignalCluster.STRUCTURE
|
||||||
|
|
||||||
|
def test_cup_handle_is_structure(self) -> None:
|
||||||
|
assert classify_signal("cup_handle") == SignalCluster.STRUCTURE
|
||||||
|
|
||||||
|
def test_atr_is_volatility(self) -> None:
|
||||||
|
assert classify_signal("atr") == SignalCluster.VOLATILITY
|
||||||
|
|
||||||
|
def test_bollinger_is_volatility(self) -> None:
|
||||||
|
assert classify_signal("bollinger") == SignalCluster.VOLATILITY
|
||||||
|
|
||||||
|
def test_valuation_is_fundamentals(self) -> None:
|
||||||
|
assert classify_signal("valuation") == SignalCluster.FUNDAMENTALS
|
||||||
|
|
||||||
|
def test_earnings_is_fundamentals(self) -> None:
|
||||||
|
assert classify_signal("earnings") == SignalCluster.FUNDAMENTALS
|
||||||
|
|
||||||
|
def test_macro_is_fundamentals(self) -> None:
|
||||||
|
assert classify_signal("macro") == SignalCluster.FUNDAMENTALS
|
||||||
|
|
||||||
|
def test_unknown_signal_defaults_to_fundamentals(self) -> None:
|
||||||
|
"""Unknown signal types fall back to FUNDAMENTALS."""
|
||||||
|
assert classify_signal("unknown_xyz") == SignalCluster.FUNDAMENTALS
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. apply_correlation_penalty — within-cluster decay (Requirement 7.2)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestWithinClusterDecay:
|
||||||
|
"""Within a cluster, strongest LR at full weight, subsequent at 0.5^(n-1)."""
|
||||||
|
|
||||||
|
def test_two_momentum_signals_decay(self) -> None:
|
||||||
|
"""Second signal in same cluster gets 0.5 decay."""
|
||||||
|
lrs = [
|
||||||
|
_lr("ma_stack", "momentum", log_lr=0.8),
|
||||||
|
_lr("rsi", "momentum", log_lr=0.5),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
# ma_stack is strongest (0.8 > 0.5) → full weight
|
||||||
|
ma = next(r for r in result if r.signal_type == "ma_stack")
|
||||||
|
rsi = next(r for r in result if r.signal_type == "rsi")
|
||||||
|
|
||||||
|
assert ma.penalized_log_lr == 0.8 # rank 0: 0.5^0 = 1.0
|
||||||
|
assert abs(rsi.penalized_log_lr - 0.5 * 0.5) < 1e-10 # rank 1: 0.5^1 = 0.5
|
||||||
|
|
||||||
|
def test_three_structure_signals_decay(self) -> None:
|
||||||
|
"""Three signals in same cluster: 1.0, 0.5, 0.25 decay."""
|
||||||
|
lrs = [
|
||||||
|
_lr("fibonacci", "structure", log_lr=1.0),
|
||||||
|
_lr("elliott_wave", "structure", log_lr=0.7),
|
||||||
|
_lr("cup_handle", "structure", log_lr=0.3),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
fib = next(r for r in result if r.signal_type == "fibonacci")
|
||||||
|
ew = next(r for r in result if r.signal_type == "elliott_wave")
|
||||||
|
ch = next(r for r in result if r.signal_type == "cup_handle")
|
||||||
|
|
||||||
|
assert fib.penalized_log_lr == 1.0 # rank 0: 1.0
|
||||||
|
assert abs(ew.penalized_log_lr - 0.7 * 0.5) < 1e-10 # rank 1: 0.5
|
||||||
|
assert abs(ch.penalized_log_lr - 0.3 * 0.25) < 1e-10 # rank 2: 0.25
|
||||||
|
|
||||||
|
def test_ranking_by_absolute_log_lr(self) -> None:
|
||||||
|
"""Ranking uses abs(log_lr), so a negative LR with large magnitude ranks first."""
|
||||||
|
lrs = [
|
||||||
|
_lr("ma_stack", "momentum", log_lr=0.3),
|
||||||
|
_lr("rsi", "momentum", log_lr=-0.9), # abs = 0.9, strongest
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
rsi = next(r for r in result if r.signal_type == "rsi")
|
||||||
|
ma = next(r for r in result if r.signal_type == "ma_stack")
|
||||||
|
|
||||||
|
# RSI is strongest by abs → full weight
|
||||||
|
assert rsi.penalized_log_lr == -0.9
|
||||||
|
# MA is second → 0.5 decay
|
||||||
|
assert abs(ma.penalized_log_lr - 0.3 * 0.5) < 1e-10
|
||||||
|
|
||||||
|
def test_decay_reduces_penalized_log_lr_magnitude(self) -> None:
|
||||||
|
"""Penalized log_lr magnitude is always <= original for non-strongest."""
|
||||||
|
lrs = [
|
||||||
|
_lr("ma_stack", "momentum", log_lr=0.8),
|
||||||
|
_lr("rsi", "momentum", log_lr=0.6),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
rsi = next(r for r in result if r.signal_type == "rsi")
|
||||||
|
assert abs(rsi.penalized_log_lr) < abs(rsi.log_lr)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. apply_correlation_penalty — cross-cluster independence (Requirement 7.3)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossClusterIndependence:
|
||||||
|
"""Signals from different clusters receive no penalty."""
|
||||||
|
|
||||||
|
def test_different_clusters_no_penalty(self) -> None:
|
||||||
|
"""Each signal in its own cluster → all at full weight."""
|
||||||
|
lrs = [
|
||||||
|
_lr("ma_stack", "momentum", log_lr=0.8),
|
||||||
|
_lr("fibonacci", "structure", log_lr=0.7),
|
||||||
|
_lr("atr", "volatility", log_lr=0.5),
|
||||||
|
_lr("valuation", "fundamentals", log_lr=0.3),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
for r in result:
|
||||||
|
assert r.penalized_log_lr == r.log_lr, (
|
||||||
|
f"{r.signal_type}: penalized_log_lr should equal log_lr "
|
||||||
|
f"when alone in cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mixed_clusters_only_same_cluster_penalized(self) -> None:
|
||||||
|
"""Two momentum + one structure: only momentum signals get decay."""
|
||||||
|
lrs = [
|
||||||
|
_lr("ma_stack", "momentum", log_lr=0.8),
|
||||||
|
_lr("rsi", "momentum", log_lr=0.5),
|
||||||
|
_lr("fibonacci", "structure", log_lr=0.6),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
ma = next(r for r in result if r.signal_type == "ma_stack")
|
||||||
|
rsi = next(r for r in result if r.signal_type == "rsi")
|
||||||
|
fib = next(r for r in result if r.signal_type == "fibonacci")
|
||||||
|
|
||||||
|
# Momentum cluster: ma_stack full, rsi decayed
|
||||||
|
assert ma.penalized_log_lr == 0.8
|
||||||
|
assert abs(rsi.penalized_log_lr - 0.5 * 0.5) < 1e-10
|
||||||
|
|
||||||
|
# Structure cluster: fibonacci alone → no penalty
|
||||||
|
assert fib.penalized_log_lr == 0.6
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4. apply_correlation_penalty — single-signal clusters (Requirement 7.4)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleSignalCluster:
|
||||||
|
"""Single-signal clusters receive no penalty."""
|
||||||
|
|
||||||
|
def test_single_signal_no_penalty(self) -> None:
|
||||||
|
"""One signal in a cluster → penalized_log_lr == log_lr."""
|
||||||
|
lrs = [_lr("fibonacci", "structure", log_lr=0.9)]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].penalized_log_lr == 0.9
|
||||||
|
|
||||||
|
def test_multiple_single_signal_clusters(self) -> None:
|
||||||
|
"""Multiple clusters each with one signal → no penalties anywhere."""
|
||||||
|
lrs = [
|
||||||
|
_lr("rsi", "momentum", log_lr=0.4),
|
||||||
|
_lr("fibonacci", "structure", log_lr=0.6),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
for r in result:
|
||||||
|
assert r.penalized_log_lr == r.log_lr
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 5. Edge cases
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Edge cases: empty input, zero log_lr, original order preserved."""
|
||||||
|
|
||||||
|
def test_empty_input_returns_empty(self) -> None:
|
||||||
|
"""Empty list → empty list."""
|
||||||
|
assert apply_correlation_penalty([]) == []
|
||||||
|
|
||||||
|
def test_zero_log_lr_no_effect(self) -> None:
|
||||||
|
"""log_lr = 0 → penalized_log_lr = 0 regardless of rank."""
|
||||||
|
lrs = [
|
||||||
|
_lr("ma_stack", "momentum", log_lr=0.5),
|
||||||
|
_lr("rsi", "momentum", log_lr=0.0),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
rsi = next(r for r in result if r.signal_type == "rsi")
|
||||||
|
assert rsi.penalized_log_lr == 0.0
|
||||||
|
|
||||||
|
def test_original_order_preserved(self) -> None:
|
||||||
|
"""Output list preserves the original input order."""
|
||||||
|
lrs = [
|
||||||
|
_lr("rsi", "momentum", log_lr=0.3),
|
||||||
|
_lr("fibonacci", "structure", log_lr=0.9),
|
||||||
|
_lr("ma_stack", "momentum", log_lr=0.8),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
assert result[0].signal_type == "rsi"
|
||||||
|
assert result[1].signal_type == "fibonacci"
|
||||||
|
assert result[2].signal_type == "ma_stack"
|
||||||
|
|
||||||
|
def test_original_objects_not_mutated(self) -> None:
|
||||||
|
"""Input LikelihoodRatio objects are not modified in place."""
|
||||||
|
original = _lr("ma_stack", "momentum", log_lr=0.8)
|
||||||
|
lrs = [
|
||||||
|
original,
|
||||||
|
_lr("rsi", "momentum", log_lr=0.5),
|
||||||
|
]
|
||||||
|
apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
# Original object should still have its initial penalized_log_lr
|
||||||
|
assert original.penalized_log_lr == 0.8
|
||||||
|
|
||||||
|
def test_negative_log_lr_decay(self) -> None:
|
||||||
|
"""Negative log_lr values are decayed correctly (toward zero)."""
|
||||||
|
lrs = [
|
||||||
|
_lr("ma_stack", "momentum", log_lr=-0.8),
|
||||||
|
_lr("rsi", "momentum", log_lr=-0.4),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
ma = next(r for r in result if r.signal_type == "ma_stack")
|
||||||
|
rsi = next(r for r in result if r.signal_type == "rsi")
|
||||||
|
|
||||||
|
# ma_stack strongest by abs → full weight
|
||||||
|
assert ma.penalized_log_lr == -0.8
|
||||||
|
# rsi second → 0.5 decay
|
||||||
|
assert abs(rsi.penalized_log_lr - (-0.4 * 0.5)) < 1e-10
|
||||||
|
|
||||||
|
def test_lr_field_unchanged_by_penalty(self) -> None:
|
||||||
|
"""The raw lr field is preserved unchanged through penalty."""
|
||||||
|
lr_val = math.exp(0.5)
|
||||||
|
lrs = [
|
||||||
|
_lr("ma_stack", "momentum", log_lr=0.8),
|
||||||
|
LikelihoodRatio(
|
||||||
|
signal_type="rsi",
|
||||||
|
cluster="momentum",
|
||||||
|
lr=lr_val,
|
||||||
|
log_lr=0.5,
|
||||||
|
penalized_log_lr=0.5,
|
||||||
|
hit_rate=0.6,
|
||||||
|
strength=0.7,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
result = apply_correlation_penalty(lrs)
|
||||||
|
|
||||||
|
rsi = next(r for r in result if r.signal_type == "rsi")
|
||||||
|
assert rsi.lr == lr_val
|
||||||
|
assert rsi.hit_rate == 0.6
|
||||||
|
assert rsi.strength == 0.7
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""Unit tests for services.signal_engine.signals.cup_handle — Cup & Handle evaluator.
|
||||||
|
|
||||||
|
Requirements: 2.4, 2.6, 2.7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection
|
||||||
|
from services.signal_engine.signals.cup_handle import (
|
||||||
|
DEFAULT_MIN_BARS,
|
||||||
|
CupHandleEvaluator,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(
|
||||||
|
close: float,
|
||||||
|
high: float | None = None,
|
||||||
|
low: float | None = None,
|
||||||
|
) -> OHLCVBar:
|
||||||
|
"""Create a minimal OHLCVBar for testing."""
|
||||||
|
h = high if high is not None else close
|
||||||
|
lo = low if low is not None else close
|
||||||
|
return OHLCVBar(
|
||||||
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
open=close,
|
||||||
|
high=h,
|
||||||
|
low=lo,
|
||||||
|
close=close,
|
||||||
|
volume=1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cup_handle_bars(
|
||||||
|
n: int = 40,
|
||||||
|
left_rim: float = 100.0,
|
||||||
|
bottom: float = 80.0,
|
||||||
|
right_rim: float = 99.0,
|
||||||
|
handle_low: float = 96.0,
|
||||||
|
) -> list[OHLCVBar]:
|
||||||
|
"""Create a synthetic cup & handle pattern.
|
||||||
|
|
||||||
|
Generates bars that form:
|
||||||
|
1. Rise to left_rim in the first third
|
||||||
|
2. Descent to bottom in the middle
|
||||||
|
3. Rise to right_rim in the last third
|
||||||
|
4. Small pullback to handle_low at the end
|
||||||
|
"""
|
||||||
|
bars: list[OHLCVBar] = []
|
||||||
|
first_third = n // 3
|
||||||
|
last_third_start = n - (n // 3)
|
||||||
|
handle_start = n - max(2, int(n * 0.15))
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
if i < first_third:
|
||||||
|
# Rise to left rim
|
||||||
|
frac = i / max(1, first_third - 1)
|
||||||
|
price = bottom + frac * (left_rim - bottom)
|
||||||
|
h = price + 1.0
|
||||||
|
lo = price - 1.0
|
||||||
|
elif i < last_third_start:
|
||||||
|
# Cup: descend to bottom then rise
|
||||||
|
mid = (first_third + last_third_start) / 2.0
|
||||||
|
if i <= mid:
|
||||||
|
frac = (i - first_third) / max(1, mid - first_third)
|
||||||
|
price = left_rim - frac * (left_rim - bottom)
|
||||||
|
else:
|
||||||
|
frac = (i - mid) / max(1, last_third_start - mid)
|
||||||
|
price = bottom + frac * (right_rim - bottom)
|
||||||
|
h = price + 1.0
|
||||||
|
lo = price - 1.0
|
||||||
|
elif i < handle_start:
|
||||||
|
# Rise to right rim
|
||||||
|
frac = (i - last_third_start) / max(1, handle_start - last_third_start - 1)
|
||||||
|
price = right_rim - 2.0 + frac * 2.0
|
||||||
|
h = price + 1.0
|
||||||
|
lo = price - 1.0
|
||||||
|
else:
|
||||||
|
# Handle: small pullback
|
||||||
|
handle_len = n - handle_start
|
||||||
|
frac = (i - handle_start) / max(1, handle_len - 1)
|
||||||
|
price = right_rim - frac * (right_rim - handle_low)
|
||||||
|
h = price + 0.5
|
||||||
|
lo = price - 0.5
|
||||||
|
|
||||||
|
bars.append(_bar(price, high=h, low=lo))
|
||||||
|
|
||||||
|
# Ensure the left rim bar has the correct high
|
||||||
|
bars[first_third - 1] = _bar(
|
||||||
|
left_rim - 1.0,
|
||||||
|
high=left_rim,
|
||||||
|
low=left_rim - 2.0,
|
||||||
|
)
|
||||||
|
# Ensure the right rim bar has the correct high
|
||||||
|
right_rim_idx = last_third_start + (handle_start - last_third_start) // 2
|
||||||
|
if right_rim_idx < n:
|
||||||
|
bars[right_rim_idx] = _bar(
|
||||||
|
right_rim - 1.0,
|
||||||
|
high=right_rim,
|
||||||
|
low=right_rim - 2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_min_bars() -> None:
|
||||||
|
assert DEFAULT_MIN_BARS == 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Insufficient data → None (Requirement 2.6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_insufficient_bars() -> None:
|
||||||
|
"""Requirement 2.6: return None when fewer than min_bars."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = [_bar(100.0) for _ in range(29)]
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_empty_bars() -> None:
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
assert evaluator.evaluate([], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_one_bar() -> None:
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
assert evaluator.evaluate([_bar(100.0)], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# No pattern detected → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_for_flat_market() -> None:
|
||||||
|
"""Flat prices have no cup formation."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = [_bar(100.0, high=100.0, low=100.0) for _ in range(40)]
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_for_monotonic_uptrend() -> None:
|
||||||
|
"""A steady uptrend has no cup shape."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = [_bar(50.0 + i * 1.0, high=51.0 + i * 1.0, low=49.0 + i * 1.0) for i in range(40)]
|
||||||
|
# Cup depth would be too shallow or non-existent
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
# Either None or invalid pattern — the uptrend doesn't form a cup
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_cup_too_shallow() -> None:
|
||||||
|
"""Cup depth < 12% should be rejected."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
# Left rim at 100, bottom at 92 → depth = 8% (too shallow)
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=92.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=97.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_cup_too_deep() -> None:
|
||||||
|
"""Cup depth > 33% should be rejected."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
# Left rim at 100, bottom at 60 → depth = 40% (too deep)
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=60.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=95.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_handle_too_deep() -> None:
|
||||||
|
"""Handle retracement > 50% of cup depth should be rejected."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
# Cup depth = 100 - 80 = 20. Handle depth > 10 (50% of 20) → rejected
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=85.0, # handle depth = 99 - 85 = 14 > 10
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Valid pattern detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_detects_valid_cup_and_handle() -> None:
|
||||||
|
"""Requirement 2.4: detect cup formation and handle."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=95.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "cup_handle"
|
||||||
|
assert result.direction == SignalDirection.BULLISH
|
||||||
|
|
||||||
|
|
||||||
|
def test_always_bullish_direction() -> None:
|
||||||
|
"""Cup & Handle is always a bullish continuation pattern."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=98.0,
|
||||||
|
handle_low=95.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.direction == SignalDirection.BULLISH
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Completeness scoring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_strength_in_unit_interval() -> None:
|
||||||
|
"""Strength must be in [0, 1]."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=96.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert 0.0 <= result.strength <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_in_unit_interval() -> None:
|
||||||
|
"""Confidence must be in [0, 1]."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=96.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert 0.0 <= result.confidence <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_proportional_to_completeness() -> None:
|
||||||
|
"""Requirement 2.4: confidence proportional to pattern completeness."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=96.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
# confidence = completeness * 0.90
|
||||||
|
expected_confidence = result.strength * 0.90
|
||||||
|
assert abs(result.confidence - expected_confidence) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_better_symmetry_yields_higher_completeness() -> None:
|
||||||
|
"""More symmetric rims should produce higher completeness."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
|
||||||
|
# Good symmetry: right rim very close to left rim
|
||||||
|
bars_good = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=100.0,
|
||||||
|
handle_low=96.0,
|
||||||
|
)
|
||||||
|
result_good = evaluator.evaluate(bars_good, "D")
|
||||||
|
|
||||||
|
# Worse symmetry: right rim further from left rim
|
||||||
|
bars_worse = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=88.0,
|
||||||
|
handle_low=85.0,
|
||||||
|
)
|
||||||
|
result_worse = evaluator.evaluate(bars_worse, "D")
|
||||||
|
|
||||||
|
if result_good is not None and result_worse is not None:
|
||||||
|
assert result_good.metadata["symmetry_score"] >= result_worse.metadata["symmetry_score"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metadata (Requirement 2.7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_contains_required_fields() -> None:
|
||||||
|
"""Metadata should include left_rim, right_rim, bottom, handle_depth, completeness."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=96.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
meta = result.metadata
|
||||||
|
assert "left_rim" in meta
|
||||||
|
assert "right_rim" in meta
|
||||||
|
assert "bottom" in meta
|
||||||
|
assert "handle_depth" in meta
|
||||||
|
assert "completeness" in meta
|
||||||
|
assert "cup_depth_pct" in meta
|
||||||
|
assert "symmetry_score" in meta
|
||||||
|
assert "handle_score" in meta
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal result structure (Requirement 2.7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_result_structure() -> None:
|
||||||
|
"""Requirement 2.7: SignalResult has all required fields."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=96.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "cup_handle"
|
||||||
|
assert result.timeframe == "D"
|
||||||
|
assert 0.0 <= result.strength <= 1.0
|
||||||
|
assert 0.0 <= result.confidence <= 1.0
|
||||||
|
assert result.direction == SignalDirection.BULLISH
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timeframe passthrough
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeframe_passthrough() -> None:
|
||||||
|
"""The timeframe label is passed through to the result."""
|
||||||
|
evaluator = CupHandleEvaluator()
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=40,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=96.0,
|
||||||
|
)
|
||||||
|
for tf in ("M30", "H1", "H4", "D", "W", "M"):
|
||||||
|
result = evaluator.evaluate(bars, tf)
|
||||||
|
assert result is not None
|
||||||
|
assert result.timeframe == tf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom min_bars
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_min_bars() -> None:
|
||||||
|
"""CupHandleEvaluator with a custom min_bars should use that value."""
|
||||||
|
evaluator = CupHandleEvaluator(min_bars=50)
|
||||||
|
assert evaluator.min_bars == 50
|
||||||
|
# 40 bars should be insufficient
|
||||||
|
bars = _make_cup_handle_bars(n=40)
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_min_bars_works() -> None:
|
||||||
|
"""Exactly min_bars should be sufficient if pattern is present."""
|
||||||
|
evaluator = CupHandleEvaluator(min_bars=30)
|
||||||
|
bars = _make_cup_handle_bars(
|
||||||
|
n=30,
|
||||||
|
left_rim=100.0,
|
||||||
|
bottom=80.0,
|
||||||
|
right_rim=99.0,
|
||||||
|
handle_low=96.0,
|
||||||
|
)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
# Should produce a result if the pattern is valid
|
||||||
|
# (may be None if the synthetic data doesn't form a clean pattern at 30 bars)
|
||||||
|
# At minimum, it should not crash
|
||||||
|
assert result is None or result.signal_type == "cup_handle"
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""Unit tests for services.signal_engine.signals.elliott_wave — Elliott Wave evaluator.
|
||||||
|
|
||||||
|
Requirements: 2.5, 2.6, 2.7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection
|
||||||
|
from services.signal_engine.signals.elliott_wave import (
|
||||||
|
DEFAULT_MIN_BARS,
|
||||||
|
WAVE_TYPE_CORRECTIVE,
|
||||||
|
WAVE_TYPE_IMPULSE,
|
||||||
|
ElliottWaveEvaluator,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(
|
||||||
|
close: float,
|
||||||
|
high: float | None = None,
|
||||||
|
low: float | None = None,
|
||||||
|
) -> OHLCVBar:
|
||||||
|
"""Create a minimal OHLCVBar for testing."""
|
||||||
|
h = high if high is not None else close
|
||||||
|
lo = low if low is not None else close
|
||||||
|
return OHLCVBar(
|
||||||
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
open=close,
|
||||||
|
high=h,
|
||||||
|
low=lo,
|
||||||
|
close=close,
|
||||||
|
volume=1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_impulse_up_bars(n: int = 50) -> list[OHLCVBar]:
|
||||||
|
"""Create synthetic bars forming a bullish 5-wave impulse pattern.
|
||||||
|
|
||||||
|
Wave structure (bullish impulse):
|
||||||
|
Wave 1: 100 → 120 (up)
|
||||||
|
Wave 2: 120 → 108 (down, retracement)
|
||||||
|
Wave 3: 108 → 140 (up, largest wave)
|
||||||
|
Wave 4: 140 → 130 (down, retracement)
|
||||||
|
Wave 5: 130 → 150 (up, new high)
|
||||||
|
"""
|
||||||
|
# Define price waypoints for each wave
|
||||||
|
waypoints = [
|
||||||
|
(0.00, 100.0), # start
|
||||||
|
(0.20, 120.0), # wave 1 peak
|
||||||
|
(0.35, 108.0), # wave 2 trough
|
||||||
|
(0.60, 140.0), # wave 3 peak
|
||||||
|
(0.75, 130.0), # wave 4 trough
|
||||||
|
(1.00, 150.0), # wave 5 peak
|
||||||
|
]
|
||||||
|
return _interpolate_bars(waypoints, n)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_impulse_down_bars(n: int = 50) -> list[OHLCVBar]:
|
||||||
|
"""Create synthetic bars forming a bearish 5-wave impulse pattern.
|
||||||
|
|
||||||
|
Wave structure (bearish impulse):
|
||||||
|
Wave 1: 150 → 130 (down)
|
||||||
|
Wave 2: 130 → 142 (up, retracement)
|
||||||
|
Wave 3: 142 → 110 (down, largest wave)
|
||||||
|
Wave 4: 110 → 120 (up, retracement)
|
||||||
|
Wave 5: 120 → 100 (down, new low)
|
||||||
|
"""
|
||||||
|
waypoints = [
|
||||||
|
(0.00, 150.0),
|
||||||
|
(0.20, 130.0),
|
||||||
|
(0.35, 142.0),
|
||||||
|
(0.60, 110.0),
|
||||||
|
(0.75, 120.0),
|
||||||
|
(1.00, 100.0),
|
||||||
|
]
|
||||||
|
return _interpolate_bars(waypoints, n)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_corrective_bars(n: int = 50) -> list[OHLCVBar]:
|
||||||
|
"""Create synthetic bars forming a corrective A-B-C pattern after an uptrend.
|
||||||
|
|
||||||
|
First half: uptrend (impulse context)
|
||||||
|
Second half: A-B-C correction
|
||||||
|
Wave A: 150 → 130 (down)
|
||||||
|
Wave B: 130 → 140 (up, partial retracement)
|
||||||
|
Wave C: 140 → 120 (down, new low)
|
||||||
|
"""
|
||||||
|
waypoints = [
|
||||||
|
(0.00, 100.0), # start of uptrend
|
||||||
|
(0.40, 150.0), # end of uptrend / start of correction
|
||||||
|
(0.60, 130.0), # wave A trough
|
||||||
|
(0.75, 140.0), # wave B peak
|
||||||
|
(1.00, 120.0), # wave C trough
|
||||||
|
]
|
||||||
|
return _interpolate_bars(waypoints, n)
|
||||||
|
|
||||||
|
|
||||||
|
def _interpolate_bars(
|
||||||
|
waypoints: list[tuple[float, float]],
|
||||||
|
n: int,
|
||||||
|
) -> list[OHLCVBar]:
|
||||||
|
"""Interpolate price waypoints into n OHLCV bars with realistic high/low."""
|
||||||
|
bars: list[OHLCVBar] = []
|
||||||
|
for i in range(n):
|
||||||
|
frac = i / max(1, n - 1)
|
||||||
|
# Find the two surrounding waypoints
|
||||||
|
price = waypoints[-1][1] # default to last
|
||||||
|
for j in range(len(waypoints) - 1):
|
||||||
|
t0, p0 = waypoints[j]
|
||||||
|
t1, p1 = waypoints[j + 1]
|
||||||
|
if t0 <= frac <= t1:
|
||||||
|
seg_frac = (frac - t0) / (t1 - t0) if t1 > t0 else 0.0
|
||||||
|
price = p0 + seg_frac * (p1 - p0)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add some spread for high/low
|
||||||
|
spread = max(1.0, abs(price) * 0.01)
|
||||||
|
bars.append(_bar(price, high=price + spread, low=price - spread))
|
||||||
|
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_min_bars() -> None:
|
||||||
|
assert DEFAULT_MIN_BARS == 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Insufficient data → None (Requirement 2.6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_insufficient_bars() -> None:
|
||||||
|
"""Requirement 2.6: return None when fewer than min_bars."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = [_bar(100.0) for _ in range(29)]
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_empty_bars() -> None:
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
assert evaluator.evaluate([], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_one_bar() -> None:
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
assert evaluator.evaluate([_bar(100.0)], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flat market → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_for_flat_market() -> None:
|
||||||
|
"""Flat prices have no wave structure."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = [_bar(100.0, high=100.0, low=100.0) for _ in range(40)]
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Impulse wave detection (Requirement 2.5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_detects_bullish_impulse_wave() -> None:
|
||||||
|
"""Requirement 2.5: detect impulse waves (5-wave structure)."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = _make_impulse_up_bars(n=50)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "elliott_wave"
|
||||||
|
assert result.direction == SignalDirection.BULLISH
|
||||||
|
assert result.metadata["wave_type"] == WAVE_TYPE_IMPULSE
|
||||||
|
|
||||||
|
|
||||||
|
def test_detects_bearish_impulse_wave() -> None:
|
||||||
|
"""Requirement 2.5: detect bearish impulse waves."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = _make_impulse_down_bars(n=50)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "elliott_wave"
|
||||||
|
assert result.direction == SignalDirection.BEARISH
|
||||||
|
assert result.metadata["wave_type"] == WAVE_TYPE_IMPULSE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Corrective wave detection (Requirement 2.5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_detects_corrective_wave() -> None:
|
||||||
|
"""Requirement 2.5: detect corrective waves (3-wave structure)."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = _make_corrective_bars(n=50)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "elliott_wave"
|
||||||
|
assert result.metadata["wave_type"] in (WAVE_TYPE_CORRECTIVE, WAVE_TYPE_IMPULSE)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal structure validation (Requirement 2.7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_result_structure() -> None:
|
||||||
|
"""Requirement 2.7: SignalResult has all required fields."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = _make_impulse_up_bars(n=50)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "elliott_wave"
|
||||||
|
assert result.timeframe == "D"
|
||||||
|
assert 0.0 <= result.strength <= 1.0
|
||||||
|
assert 0.0 <= result.confidence <= 1.0
|
||||||
|
assert result.direction in (
|
||||||
|
SignalDirection.BULLISH,
|
||||||
|
SignalDirection.BEARISH,
|
||||||
|
SignalDirection.NEUTRAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_strength_in_unit_interval() -> None:
|
||||||
|
"""Strength must be in [0, 1]."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = _make_impulse_up_bars(n=50)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert 0.0 <= result.strength <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_in_unit_interval() -> None:
|
||||||
|
"""Confidence must be in [0, 1]."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = _make_impulse_up_bars(n=50)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert 0.0 <= result.confidence <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metadata (Requirement 2.7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_contains_required_fields() -> None:
|
||||||
|
"""Metadata should include wave_count, wave_type, current_wave_position, pivots."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = _make_impulse_up_bars(n=50)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
meta = result.metadata
|
||||||
|
assert "wave_count" in meta
|
||||||
|
assert "wave_type" in meta
|
||||||
|
assert "current_wave_position" in meta
|
||||||
|
assert "pivots" in meta
|
||||||
|
assert isinstance(meta["pivots"], list)
|
||||||
|
assert len(meta["pivots"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timeframe passthrough
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeframe_passthrough() -> None:
|
||||||
|
"""The timeframe label is passed through to the result."""
|
||||||
|
evaluator = ElliottWaveEvaluator()
|
||||||
|
bars = _make_impulse_up_bars(n=50)
|
||||||
|
for tf in ("M30", "H1", "H4", "D", "W", "M"):
|
||||||
|
result = evaluator.evaluate(bars, tf)
|
||||||
|
assert result is not None
|
||||||
|
assert result.timeframe == tf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom min_bars
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_min_bars() -> None:
|
||||||
|
"""ElliottWaveEvaluator with a custom min_bars should use that value."""
|
||||||
|
evaluator = ElliottWaveEvaluator(min_bars=60)
|
||||||
|
assert evaluator.min_bars == 60
|
||||||
|
# 50 bars should be insufficient
|
||||||
|
bars = _make_impulse_up_bars(n=50)
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_zigzag_pct() -> None:
|
||||||
|
"""Custom zigzag_pct should be stored and used."""
|
||||||
|
evaluator = ElliottWaveEvaluator(zigzag_pct=0.10)
|
||||||
|
assert evaluator.zigzag_pct == 0.10
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
"""Unit tests for services.signal_engine.exit_engine — Exit Engine.
|
||||||
|
|
||||||
|
Tests stop-loss triggers, target-1 partial exits, target-2 full exits,
|
||||||
|
trailing stop activation/ratchet behavior, priority ordering, empty
|
||||||
|
positions, and fallback to position.current_price when ticker is absent
|
||||||
|
from current_prices.
|
||||||
|
|
||||||
|
Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.signal_engine.config import ExitConfig
|
||||||
|
from services.signal_engine.exit_engine import evaluate_exits
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
ExitType,
|
||||||
|
OpenPositionState,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _default_config() -> ExitConfig:
|
||||||
|
return ExitConfig(trailing_stop_atr_multiplier=2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _position(
|
||||||
|
*,
|
||||||
|
position_id: str = "pos-1",
|
||||||
|
ticker: str = "AAPL",
|
||||||
|
entry_price: float = 100.0,
|
||||||
|
current_price: float = 110.0,
|
||||||
|
stop_loss: float = 90.0,
|
||||||
|
target_1: float = 115.0,
|
||||||
|
target_2: float = 130.0,
|
||||||
|
trailing_stop: float | None = None,
|
||||||
|
partial_exit_done: bool = False,
|
||||||
|
atr: float | None = 5.0,
|
||||||
|
) -> OpenPositionState:
|
||||||
|
return OpenPositionState(
|
||||||
|
position_id=position_id,
|
||||||
|
ticker=ticker,
|
||||||
|
entry_price=entry_price,
|
||||||
|
current_price=current_price,
|
||||||
|
stop_loss=stop_loss,
|
||||||
|
target_1=target_1,
|
||||||
|
target_2=target_2,
|
||||||
|
trailing_stop=trailing_stop,
|
||||||
|
partial_exit_done=partial_exit_done,
|
||||||
|
atr=atr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. Stop-loss trigger (Requirement 8.1)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestStopLoss:
|
||||||
|
"""Stop-loss hit → EXIT_FULL with reason 'stop_hit'."""
|
||||||
|
|
||||||
|
def test_stop_loss_exact_hit(self) -> None:
|
||||||
|
"""Price exactly at stop_loss triggers exit."""
|
||||||
|
pos = _position(stop_loss=90.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 90.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].exit_type == ExitType.EXIT_FULL
|
||||||
|
assert signals[0].reason == "stop_hit"
|
||||||
|
assert signals[0].price == 90.0
|
||||||
|
assert signals[0].position_id == "pos-1"
|
||||||
|
assert signals[0].ticker == "AAPL"
|
||||||
|
|
||||||
|
def test_stop_loss_below(self) -> None:
|
||||||
|
"""Price below stop_loss triggers exit."""
|
||||||
|
pos = _position(stop_loss=90.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 85.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].exit_type == ExitType.EXIT_FULL
|
||||||
|
assert signals[0].reason == "stop_hit"
|
||||||
|
assert signals[0].price == 85.0
|
||||||
|
|
||||||
|
def test_stop_loss_has_highest_priority(self) -> None:
|
||||||
|
"""Stop-loss takes priority even when target_2 is also hit.
|
||||||
|
|
||||||
|
This can happen if stop_loss >= target_2 due to misconfiguration,
|
||||||
|
or if the price gaps through both levels.
|
||||||
|
"""
|
||||||
|
# Contrived: stop_loss at 130, target_2 at 120 (misconfigured)
|
||||||
|
pos = _position(stop_loss=130.0, target_2=120.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 125.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].reason == "stop_hit"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. Target-1 partial exit (Requirement 8.2)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestTarget1:
|
||||||
|
"""Target-1 hit → EXIT_HALF with reason 'target_1_hit'."""
|
||||||
|
|
||||||
|
def test_target_1_exact_hit(self) -> None:
|
||||||
|
"""Price exactly at target_1 triggers partial exit."""
|
||||||
|
pos = _position(target_1=115.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 115.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].exit_type == ExitType.EXIT_HALF
|
||||||
|
assert signals[0].reason == "target_1_hit"
|
||||||
|
assert signals[0].price == 115.0
|
||||||
|
|
||||||
|
def test_target_1_above(self) -> None:
|
||||||
|
"""Price above target_1 (but below target_2) triggers partial exit."""
|
||||||
|
pos = _position(target_1=115.0, target_2=130.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].exit_type == ExitType.EXIT_HALF
|
||||||
|
assert signals[0].reason == "target_1_hit"
|
||||||
|
|
||||||
|
def test_target_1_not_triggered_when_partial_exit_done(self) -> None:
|
||||||
|
"""Target-1 is skipped when partial_exit_done is True."""
|
||||||
|
pos = _position(target_1=115.0, target_2=130.0, partial_exit_done=True, atr=5.0)
|
||||||
|
# Price above target_1 but below target_2, trailing stop not hit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
|
||||||
|
|
||||||
|
# No target_1_hit signal; trailing stop is 120 - 5*2 = 110, not hit
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. Target-2 full exit (Requirement 8.3)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestTarget2:
|
||||||
|
"""Target-2 hit → EXIT_FULL with reason 'target_2_hit'."""
|
||||||
|
|
||||||
|
def test_target_2_exact_hit(self) -> None:
|
||||||
|
"""Price exactly at target_2 triggers full exit."""
|
||||||
|
pos = _position(target_2=130.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 130.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].exit_type == ExitType.EXIT_FULL
|
||||||
|
assert signals[0].reason == "target_2_hit"
|
||||||
|
assert signals[0].price == 130.0
|
||||||
|
|
||||||
|
def test_target_2_above(self) -> None:
|
||||||
|
"""Price above target_2 triggers full exit."""
|
||||||
|
pos = _position(target_2=130.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 140.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].exit_type == ExitType.EXIT_FULL
|
||||||
|
assert signals[0].reason == "target_2_hit"
|
||||||
|
|
||||||
|
def test_target_2_priority_over_target_1(self) -> None:
|
||||||
|
"""When price hits both target_1 and target_2, target_2 wins."""
|
||||||
|
pos = _position(target_1=115.0, target_2=130.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 135.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].reason == "target_2_hit"
|
||||||
|
assert signals[0].exit_type == ExitType.EXIT_FULL
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4. Trailing stop activation and ratchet (Requirement 8.4)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrailingStop:
|
||||||
|
"""Trailing stop activates after partial exit and ratchets upward."""
|
||||||
|
|
||||||
|
def test_trailing_stop_not_active_before_partial_exit(self) -> None:
|
||||||
|
"""Trailing stop does not trigger when partial_exit_done is False."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=False,
|
||||||
|
trailing_stop=108.0,
|
||||||
|
atr=5.0,
|
||||||
|
target_1=115.0,
|
||||||
|
target_2=130.0,
|
||||||
|
stop_loss=90.0,
|
||||||
|
)
|
||||||
|
# Price at 107 is below trailing_stop=108, but trailing is not active
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 107.0}, _default_config())
|
||||||
|
|
||||||
|
# No trailing stop signal; price is above stop_loss and below targets
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
def test_trailing_stop_computed_from_atr(self) -> None:
|
||||||
|
"""Trailing stop = price - ATR * multiplier when no existing stop."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=None,
|
||||||
|
atr=5.0,
|
||||||
|
target_2=150.0,
|
||||||
|
)
|
||||||
|
# Price = 120, trailing = 120 - 5*2 = 110, price > 110 → no exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
def test_trailing_stop_ratchets_upward(self) -> None:
|
||||||
|
"""New trailing stop level is used only if higher than existing."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=112.0, # existing high trailing stop
|
||||||
|
atr=5.0,
|
||||||
|
target_2=150.0,
|
||||||
|
)
|
||||||
|
# Price = 120, new trailing = 120 - 10 = 110 < existing 112
|
||||||
|
# Effective trailing = 112 (ratchet keeps higher value)
|
||||||
|
# Price 120 > 112 → no exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
def test_trailing_stop_updates_when_price_advances(self) -> None:
|
||||||
|
"""Higher price produces higher trailing stop level."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=105.0, # old trailing stop
|
||||||
|
atr=5.0,
|
||||||
|
target_2=200.0,
|
||||||
|
)
|
||||||
|
# Price = 130, new trailing = 130 - 10 = 120 > existing 105
|
||||||
|
# Effective trailing = 120, price 130 > 120 → no exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 130.0}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
def test_trailing_stop_no_atr_uses_existing(self) -> None:
|
||||||
|
"""When ATR is None, existing trailing_stop is used as-is."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=115.0,
|
||||||
|
atr=None,
|
||||||
|
target_2=150.0,
|
||||||
|
)
|
||||||
|
# Price = 120 > trailing 115 → no exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
def test_trailing_stop_no_atr_no_existing_returns_zero(self) -> None:
|
||||||
|
"""When ATR is None and trailing_stop is None, effective stop is 0."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=None,
|
||||||
|
atr=None,
|
||||||
|
target_2=150.0,
|
||||||
|
)
|
||||||
|
# Effective trailing = 0.0, price 120 > 0 → no exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 5. Trailing stop hit (Requirement 8.5)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrailingStopHit:
|
||||||
|
"""Trailing stop hit → EXIT_FULL with reason 'trailing_stop_hit'."""
|
||||||
|
|
||||||
|
def test_trailing_stop_hit_exact(self) -> None:
|
||||||
|
"""Price exactly at trailing stop triggers exit."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=115.0,
|
||||||
|
atr=5.0,
|
||||||
|
target_2=150.0,
|
||||||
|
)
|
||||||
|
# Price = 115, new trailing = 115 - 10 = 105 < existing 115
|
||||||
|
# Effective trailing = 115, price 115 <= 115 → exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 115.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].exit_type == ExitType.EXIT_FULL
|
||||||
|
assert signals[0].reason == "trailing_stop_hit"
|
||||||
|
assert signals[0].price == 115.0
|
||||||
|
|
||||||
|
def test_trailing_stop_hit_below(self) -> None:
|
||||||
|
"""Price below trailing stop triggers exit."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=115.0,
|
||||||
|
atr=5.0,
|
||||||
|
target_2=150.0,
|
||||||
|
)
|
||||||
|
# Price = 110, new trailing = 110 - 10 = 100 < existing 115
|
||||||
|
# Effective trailing = 115, price 110 <= 115 → exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 110.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].reason == "trailing_stop_hit"
|
||||||
|
|
||||||
|
def test_trailing_stop_hit_computed_from_atr(self) -> None:
|
||||||
|
"""Trailing stop computed from ATR triggers exit when price drops."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=None, # no existing trailing stop
|
||||||
|
atr=3.0,
|
||||||
|
target_2=150.0,
|
||||||
|
)
|
||||||
|
# Price = 100, trailing = 100 - 3*2 = 94, max(0, 94) = 94
|
||||||
|
# Price 100 > 94 → no exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 100.0}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
# Now price drops to 93 → trailing = 93 - 6 = 87, max(0, 87) = 87
|
||||||
|
# Price 93 > 87 → still no exit (trailing recomputed each call)
|
||||||
|
signals2 = evaluate_exits([pos], {"AAPL": 93.0}, _default_config())
|
||||||
|
assert len(signals2) == 0
|
||||||
|
|
||||||
|
def test_trailing_stop_hit_with_high_existing_stop(self) -> None:
|
||||||
|
"""Existing high trailing stop triggers exit when price drops to it."""
|
||||||
|
pos = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=118.0, # previously ratcheted up
|
||||||
|
atr=5.0,
|
||||||
|
target_2=150.0,
|
||||||
|
)
|
||||||
|
# Price = 117, new trailing = 117 - 10 = 107 < existing 118
|
||||||
|
# Effective trailing = 118, price 117 <= 118 → exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 117.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].reason == "trailing_stop_hit"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 6. No exit when price is between stop and targets
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoExit:
|
||||||
|
"""No exit signal when price is in the safe zone."""
|
||||||
|
|
||||||
|
def test_price_between_stop_and_target_1(self) -> None:
|
||||||
|
"""Price above stop_loss and below target_1 → no exit."""
|
||||||
|
pos = _position(stop_loss=90.0, target_1=115.0, target_2=130.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 105.0}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
def test_price_just_above_stop_loss(self) -> None:
|
||||||
|
"""Price barely above stop_loss → no exit."""
|
||||||
|
pos = _position(stop_loss=90.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 90.01}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
def test_price_just_below_target_1(self) -> None:
|
||||||
|
"""Price barely below target_1 → no exit."""
|
||||||
|
pos = _position(target_1=115.0)
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 114.99}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 7. Empty positions list
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyPositions:
|
||||||
|
"""Empty positions list returns empty signals list."""
|
||||||
|
|
||||||
|
def test_empty_positions(self) -> None:
|
||||||
|
signals = evaluate_exits([], {"AAPL": 100.0}, _default_config())
|
||||||
|
assert signals == []
|
||||||
|
|
||||||
|
def test_empty_positions_empty_prices(self) -> None:
|
||||||
|
signals = evaluate_exits([], {}, _default_config())
|
||||||
|
assert signals == []
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 8. Fallback to position.current_price when ticker not in current_prices
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestPriceFallback:
|
||||||
|
"""When ticker is absent from current_prices, use position.current_price."""
|
||||||
|
|
||||||
|
def test_uses_position_current_price_as_fallback(self) -> None:
|
||||||
|
"""Ticker not in current_prices → falls back to position.current_price."""
|
||||||
|
pos = _position(
|
||||||
|
ticker="MSFT",
|
||||||
|
current_price=85.0, # below stop_loss
|
||||||
|
stop_loss=90.0,
|
||||||
|
)
|
||||||
|
# "MSFT" not in current_prices → uses 85.0
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 200.0}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].reason == "stop_hit"
|
||||||
|
assert signals[0].price == 85.0
|
||||||
|
|
||||||
|
def test_uses_current_prices_when_available(self) -> None:
|
||||||
|
"""Ticker in current_prices → uses that price, not position.current_price."""
|
||||||
|
pos = _position(
|
||||||
|
ticker="AAPL",
|
||||||
|
current_price=85.0, # would trigger stop
|
||||||
|
stop_loss=90.0,
|
||||||
|
)
|
||||||
|
# current_prices has AAPL at 105 → above stop, no exit
|
||||||
|
signals = evaluate_exits([pos], {"AAPL": 105.0}, _default_config())
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
def test_fallback_triggers_target(self) -> None:
|
||||||
|
"""Fallback price can trigger target exits too."""
|
||||||
|
pos = _position(
|
||||||
|
ticker="TSLA",
|
||||||
|
current_price=135.0, # above target_2
|
||||||
|
target_2=130.0,
|
||||||
|
)
|
||||||
|
signals = evaluate_exits([pos], {}, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0].reason == "target_2_hit"
|
||||||
|
assert signals[0].price == 135.0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 9. Multiple positions
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiplePositions:
|
||||||
|
"""Multiple positions evaluated independently."""
|
||||||
|
|
||||||
|
def test_multiple_positions_different_exits(self) -> None:
|
||||||
|
"""Each position evaluated independently; different exit types."""
|
||||||
|
pos1 = _position(position_id="p1", ticker="AAPL", stop_loss=90.0)
|
||||||
|
pos2 = _position(position_id="p2", ticker="MSFT", stop_loss=40.0, target_1=50.0, target_2=130.0)
|
||||||
|
pos3 = _position(position_id="p3", ticker="GOOG")
|
||||||
|
|
||||||
|
prices = {"AAPL": 85.0, "MSFT": 55.0, "GOOG": 105.0}
|
||||||
|
signals = evaluate_exits([pos1, pos2, pos3], prices, _default_config())
|
||||||
|
|
||||||
|
assert len(signals) == 2 # AAPL stop hit, MSFT target_1 hit, GOOG no exit
|
||||||
|
|
||||||
|
by_id = {s.position_id: s for s in signals}
|
||||||
|
assert by_id["p1"].reason == "stop_hit"
|
||||||
|
assert by_id["p2"].reason == "target_1_hit"
|
||||||
|
assert "p3" not in by_id
|
||||||
|
|
||||||
|
def test_all_positions_no_exit(self) -> None:
|
||||||
|
"""All positions in safe zone → empty signals."""
|
||||||
|
pos1 = _position(position_id="p1", stop_loss=80.0, target_1=120.0)
|
||||||
|
pos2 = _position(position_id="p2", stop_loss=80.0, target_1=120.0)
|
||||||
|
|
||||||
|
signals = evaluate_exits(
|
||||||
|
[pos1, pos2],
|
||||||
|
{"AAPL": 100.0},
|
||||||
|
_default_config(),
|
||||||
|
)
|
||||||
|
assert len(signals) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 10. Custom config — trailing_stop_atr_multiplier
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomExitConfig:
|
||||||
|
"""Custom ATR multiplier affects trailing stop computation."""
|
||||||
|
|
||||||
|
def test_higher_multiplier_wider_trailing_stop(self) -> None:
|
||||||
|
"""Higher multiplier → wider trailing stop → less likely to trigger."""
|
||||||
|
# Use a pre-set trailing_stop that was ratcheted up previously.
|
||||||
|
# With tight config the existing trailing stop triggers; with wide
|
||||||
|
# config we use a lower existing stop that doesn't trigger.
|
||||||
|
pos_tight = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=110.0, # previously ratcheted high
|
||||||
|
atr=5.0,
|
||||||
|
target_2=200.0,
|
||||||
|
)
|
||||||
|
pos_wide = _position(
|
||||||
|
partial_exit_done=True,
|
||||||
|
trailing_stop=100.0, # lower trailing stop
|
||||||
|
atr=5.0,
|
||||||
|
target_2=200.0,
|
||||||
|
)
|
||||||
|
config = _default_config()
|
||||||
|
|
||||||
|
# Price at 108: tight trailing=max(110, 108-10)=110 → hit
|
||||||
|
signals_tight = evaluate_exits([pos_tight], {"AAPL": 108.0}, config)
|
||||||
|
# Price at 108: wide trailing=max(100, 108-10)=100 → not hit (108 > 100)
|
||||||
|
signals_wide = evaluate_exits([pos_wide], {"AAPL": 108.0}, config)
|
||||||
|
|
||||||
|
assert len(signals_tight) == 1
|
||||||
|
assert signals_tight[0].reason == "trailing_stop_hit"
|
||||||
|
assert len(signals_wide) == 0
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Unit tests for services.signal_engine.signals.fibonacci — Fibonacci retracement evaluator.
|
||||||
|
|
||||||
|
Requirements: 2.1, 2.6, 2.7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection
|
||||||
|
from services.signal_engine.signals.fibonacci import (
|
||||||
|
DEFAULT_MIN_BARS,
|
||||||
|
RETRACEMENT_RATIOS,
|
||||||
|
FibonacciEvaluator,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(
|
||||||
|
close: float,
|
||||||
|
high: float | None = None,
|
||||||
|
low: float | None = None,
|
||||||
|
) -> OHLCVBar:
|
||||||
|
"""Create a minimal OHLCVBar for testing."""
|
||||||
|
return OHLCVBar(
|
||||||
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
open=close,
|
||||||
|
high=high if high is not None else close,
|
||||||
|
low=low if low is not None else close,
|
||||||
|
close=close,
|
||||||
|
volume=1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bars(
|
||||||
|
n: int,
|
||||||
|
close: float = 100.0,
|
||||||
|
high: float | None = None,
|
||||||
|
low: float | None = None,
|
||||||
|
) -> list[OHLCVBar]:
|
||||||
|
"""Create *n* identical bars."""
|
||||||
|
return [_bar(close, high=high, low=low) for _ in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Insufficient data → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_insufficient_bars() -> None:
|
||||||
|
"""Requirement 2.6: return None when fewer bars than lookback."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=20)
|
||||||
|
bars = _make_bars(10, close=100.0, high=110.0, low=90.0)
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_empty_bars() -> None:
|
||||||
|
evaluator = FibonacciEvaluator()
|
||||||
|
assert evaluator.evaluate([], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_flat_market() -> None:
|
||||||
|
"""When SH == SL there is no valid retracement range."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=5)
|
||||||
|
# All bars have the same high and low
|
||||||
|
bars = _make_bars(5, close=100.0, high=100.0, low=100.0)
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Basic signal production
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_produces_signal_with_sufficient_data() -> None:
|
||||||
|
"""Requirement 2.7: produces a valid SignalResult."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=5)
|
||||||
|
# Create bars with a clear swing high and swing low
|
||||||
|
bars = [
|
||||||
|
_bar(100.0, high=100.0, low=95.0),
|
||||||
|
_bar(105.0, high=110.0, low=100.0), # swing high
|
||||||
|
_bar(95.0, high=100.0, low=90.0), # swing low
|
||||||
|
_bar(98.0, high=100.0, low=95.0),
|
||||||
|
_bar(100.0, high=102.0, low=98.0), # current price = 100
|
||||||
|
]
|
||||||
|
result = evaluator.evaluate(bars, "H4")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "fibonacci"
|
||||||
|
assert result.timeframe == "H4"
|
||||||
|
assert 0.0 <= result.strength <= 1.0
|
||||||
|
assert 0.0 <= result.confidence <= 1.0
|
||||||
|
assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_metadata_contains_expected_keys() -> None:
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=5)
|
||||||
|
bars = [
|
||||||
|
_bar(100.0, high=100.0, low=95.0),
|
||||||
|
_bar(105.0, high=110.0, low=100.0),
|
||||||
|
_bar(95.0, high=100.0, low=90.0),
|
||||||
|
_bar(98.0, high=100.0, low=95.0),
|
||||||
|
_bar(100.0, high=102.0, low=98.0),
|
||||||
|
]
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
meta = result.metadata
|
||||||
|
assert "swing_high" in meta
|
||||||
|
assert "swing_low" in meta
|
||||||
|
assert "retracement_levels" in meta
|
||||||
|
assert "nearest_ratio" in meta
|
||||||
|
assert "nearest_level" in meta
|
||||||
|
assert "distance_to_nearest" in meta
|
||||||
|
assert "current_price" in meta
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Retracement level formula: L(r) = SH - r * (SH - SL)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_retracement_levels_formula() -> None:
|
||||||
|
"""Requirement 2.1: L(r) = SH - r * (SH - SL) for all standard ratios."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=5)
|
||||||
|
# SH = 200, SL = 100 → range = 100
|
||||||
|
bars = [
|
||||||
|
_bar(150.0, high=200.0, low=100.0), # contains both SH and SL
|
||||||
|
_bar(150.0, high=180.0, low=120.0),
|
||||||
|
_bar(150.0, high=170.0, low=130.0),
|
||||||
|
_bar(150.0, high=160.0, low=140.0),
|
||||||
|
_bar(150.0, high=155.0, low=145.0), # current close = 150
|
||||||
|
]
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
levels = result.metadata["retracement_levels"]
|
||||||
|
sh = result.metadata["swing_high"]
|
||||||
|
sl = result.metadata["swing_low"]
|
||||||
|
assert sh == 200.0
|
||||||
|
assert sl == 100.0
|
||||||
|
|
||||||
|
for ratio in RETRACEMENT_RATIOS:
|
||||||
|
expected = sh - ratio * (sh - sl)
|
||||||
|
assert abs(levels[ratio] - expected) < 1e-10, (
|
||||||
|
f"Level for ratio {ratio}: expected {expected}, got {levels[ratio]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retracement_ratios_constant() -> None:
|
||||||
|
"""Verify the RETRACEMENT_RATIOS constant matches the spec."""
|
||||||
|
assert RETRACEMENT_RATIOS == [0.236, 0.382, 0.5, 0.618, 0.786]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal strength — proximity to nearest level
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_strength_is_high_when_price_at_level() -> None:
|
||||||
|
"""When current price is exactly at a retracement level, strength ≈ 1.0."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=5)
|
||||||
|
# SH = 200, SL = 100, range = 100
|
||||||
|
# 0.5 level = 200 - 0.5 * 100 = 150
|
||||||
|
# Set current close exactly at the 0.5 level
|
||||||
|
bars = [
|
||||||
|
_bar(150.0, high=200.0, low=100.0),
|
||||||
|
_bar(160.0, high=180.0, low=120.0),
|
||||||
|
_bar(140.0, high=170.0, low=110.0),
|
||||||
|
_bar(155.0, high=165.0, low=130.0),
|
||||||
|
_bar(150.0, high=155.0, low=145.0), # close = 150 = 0.5 level
|
||||||
|
]
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.strength == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_strength_decreases_with_distance() -> None:
|
||||||
|
"""Strength should be lower when price is far from any retracement level."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=5)
|
||||||
|
# SH = 200, SL = 100
|
||||||
|
# Levels: 176.4, 161.8, 150.0, 138.2, 121.4
|
||||||
|
# Price at 110 → nearest is 121.4, distance = 11.4, strength = 1 - 11.4/100 = 0.886
|
||||||
|
bars = [
|
||||||
|
_bar(150.0, high=200.0, low=100.0),
|
||||||
|
_bar(160.0, high=180.0, low=120.0),
|
||||||
|
_bar(140.0, high=170.0, low=110.0),
|
||||||
|
_bar(130.0, high=165.0, low=105.0),
|
||||||
|
_bar(110.0, high=115.0, low=105.0), # close = 110
|
||||||
|
]
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.strength < 1.0
|
||||||
|
assert result.strength > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Direction logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_direction_bullish_when_above_swing_low() -> None:
|
||||||
|
"""Price above SL → BULLISH (potential bounce)."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=5)
|
||||||
|
# SL = 100, current close = 150 (above SL)
|
||||||
|
bars = [
|
||||||
|
_bar(150.0, high=200.0, low=100.0),
|
||||||
|
_bar(160.0, high=180.0, low=120.0),
|
||||||
|
_bar(140.0, high=170.0, low=110.0),
|
||||||
|
_bar(155.0, high=165.0, low=130.0),
|
||||||
|
_bar(150.0, high=155.0, low=145.0),
|
||||||
|
]
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.direction == SignalDirection.BULLISH
|
||||||
|
|
||||||
|
|
||||||
|
def test_direction_bearish_when_below_swing_low() -> None:
|
||||||
|
"""Price below SL → BEARISH."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=3)
|
||||||
|
# SH = 200 (bar 0 high), SL = 100 (bar 1 low)
|
||||||
|
# Current close = 95 (below SL of 100)
|
||||||
|
# The last bar's low must not be lower than SL, otherwise SL shifts down
|
||||||
|
bars = [
|
||||||
|
_bar(150.0, high=200.0, low=110.0),
|
||||||
|
_bar(120.0, high=150.0, low=100.0),
|
||||||
|
_bar(95.0, high=100.0, low=100.0), # close = 95 < SL=100, but low stays at 100
|
||||||
|
]
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.direction == SignalDirection.BEARISH
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Confidence — key ratios boost confidence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_higher_at_key_ratio() -> None:
|
||||||
|
"""Confidence should be boosted when nearest level is 0.5 or 0.618."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=5)
|
||||||
|
# SH = 200, SL = 100
|
||||||
|
# 0.5 level = 150, 0.618 level = 138.2
|
||||||
|
# Price at 150 → nearest is 0.5 (key ratio)
|
||||||
|
bars_at_key = [
|
||||||
|
_bar(150.0, high=200.0, low=100.0),
|
||||||
|
_bar(160.0, high=180.0, low=120.0),
|
||||||
|
_bar(140.0, high=170.0, low=110.0),
|
||||||
|
_bar(155.0, high=165.0, low=130.0),
|
||||||
|
_bar(150.0, high=155.0, low=145.0), # at 0.5 level
|
||||||
|
]
|
||||||
|
result_key = evaluator.evaluate(bars_at_key, "D")
|
||||||
|
|
||||||
|
# Price at 176.4 → nearest is 0.236 (non-key ratio)
|
||||||
|
bars_at_nonkey = [
|
||||||
|
_bar(150.0, high=200.0, low=100.0),
|
||||||
|
_bar(160.0, high=180.0, low=120.0),
|
||||||
|
_bar(170.0, high=175.0, low=165.0),
|
||||||
|
_bar(175.0, high=178.0, low=172.0),
|
||||||
|
_bar(176.4, high=178.0, low=174.0), # at 0.236 level
|
||||||
|
]
|
||||||
|
result_nonkey = evaluator.evaluate(bars_at_nonkey, "D")
|
||||||
|
|
||||||
|
assert result_key is not None
|
||||||
|
assert result_nonkey is not None
|
||||||
|
# Both at their respective levels (distance ≈ 0), but key ratio gets higher confidence
|
||||||
|
assert result_key.confidence > result_nonkey.confidence
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configurable min_bars
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_min_bars() -> None:
|
||||||
|
"""The lookback is configurable via constructor."""
|
||||||
|
evaluator = FibonacciEvaluator(min_bars=3)
|
||||||
|
bars = [
|
||||||
|
_bar(100.0, high=120.0, low=80.0),
|
||||||
|
_bar(110.0, high=115.0, low=90.0),
|
||||||
|
_bar(105.0, high=110.0, low=95.0),
|
||||||
|
]
|
||||||
|
result = evaluator.evaluate(bars, "M30")
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_min_bars_value() -> None:
|
||||||
|
assert DEFAULT_MIN_BARS == 20
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
"""Unit tests for services.signal_engine.formatter — Output Formatter.
|
||||||
|
|
||||||
|
Tests trade plan generation for dual_confirmed, probabilistic_only,
|
||||||
|
heuristic-only, and no-BUY cases. Also tests the
|
||||||
|
``signal_output_to_recommendation`` mapping to the existing
|
||||||
|
``Recommendation`` schema.
|
||||||
|
|
||||||
|
Requirements: 10.2, 10.3, 10.4, 12.3, 12.4
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.shared.schemas import ActionType, RecommendationMode
|
||||||
|
from services.signal_engine.config import SignalEngineConfig
|
||||||
|
from services.signal_engine.formatter import (
|
||||||
|
format_output,
|
||||||
|
signal_output_to_recommendation,
|
||||||
|
)
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
DeltaResult,
|
||||||
|
ExitSignal,
|
||||||
|
ExitType,
|
||||||
|
HeuristicResult,
|
||||||
|
ProbabilisticResult,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _default_config() -> SignalEngineConfig:
|
||||||
|
return SignalEngineConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def _heuristic(
|
||||||
|
verdict: Verdict = Verdict.BUY,
|
||||||
|
confidence: float = 0.85,
|
||||||
|
s_total: float = 1.5,
|
||||||
|
) -> HeuristicResult:
|
||||||
|
return HeuristicResult(
|
||||||
|
verdict=verdict,
|
||||||
|
confidence=confidence,
|
||||||
|
s_total=s_total,
|
||||||
|
s_company=1.0,
|
||||||
|
s_macro=0.3,
|
||||||
|
s_competitive=0.2,
|
||||||
|
signal_weights=[],
|
||||||
|
reasoning=[f"{verdict.value} verdict"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probabilistic(
|
||||||
|
verdict: Verdict = Verdict.BUY,
|
||||||
|
p_up: float = 0.75,
|
||||||
|
entropy: float = 0.6,
|
||||||
|
ev_r: float = 2.0,
|
||||||
|
) -> ProbabilisticResult:
|
||||||
|
return ProbabilisticResult(
|
||||||
|
verdict=verdict,
|
||||||
|
p_up=p_up,
|
||||||
|
entropy=entropy,
|
||||||
|
ev_r=ev_r,
|
||||||
|
prior=0.58,
|
||||||
|
posterior=0.75,
|
||||||
|
likelihood_ratios=[],
|
||||||
|
regime="bull",
|
||||||
|
reasoning=[f"{verdict.value} verdict"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delta(
|
||||||
|
agreement: bool = True,
|
||||||
|
confidence_delta: float = 0.1,
|
||||||
|
reasons: list[str] | None = None,
|
||||||
|
) -> DeltaResult:
|
||||||
|
return DeltaResult(
|
||||||
|
agreement=agreement,
|
||||||
|
confidence_delta=confidence_delta,
|
||||||
|
heuristic_verdict="BUY",
|
||||||
|
probabilistic_verdict="BUY",
|
||||||
|
disagreement_reasons=reasons or [],
|
||||||
|
rolling_agreement_rate=0.85,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. Dual confirmed trade plan (Requirement 10.4)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestDualConfirmed:
|
||||||
|
"""Both pipelines BUY → dual_confirmed, full position sizing."""
|
||||||
|
|
||||||
|
def test_dual_confirmed_trade_plan(self) -> None:
|
||||||
|
"""Both BUY → trade_plan with dual_confirmed=True."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is not None
|
||||||
|
assert output.trade_plan.dual_confirmed is True
|
||||||
|
assert output.trade_plan.probabilistic_only is False
|
||||||
|
|
||||||
|
def test_dual_confirmed_full_position_sizing(self) -> None:
|
||||||
|
"""Dual confirmed → position_size_pct=0.02, max_loss_pct=0.005."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is not None
|
||||||
|
assert output.trade_plan.position_size_pct == 0.02
|
||||||
|
assert output.trade_plan.max_loss_pct == 0.005
|
||||||
|
|
||||||
|
def test_dual_confirmed_price_levels(self) -> None:
|
||||||
|
"""Trade plan price levels: stop=95%, target_1=105%, target_2=110%."""
|
||||||
|
price = 200.0
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=price,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
tp = output.trade_plan
|
||||||
|
assert tp is not None
|
||||||
|
assert tp.entry_price == price
|
||||||
|
assert abs(tp.stop_loss - price * 0.95) < 1e-6
|
||||||
|
assert abs(tp.target_1 - price * 1.05) < 1e-6
|
||||||
|
assert abs(tp.target_2 - price * 1.10) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. Probabilistic-only trade plan (Requirement 10.3)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbabilisticOnly:
|
||||||
|
"""Probabilistic BUY, heuristic not BUY → probabilistic_only, 50% sizing."""
|
||||||
|
|
||||||
|
def test_probabilistic_only_trade_plan(self) -> None:
|
||||||
|
"""Probabilistic BUY + heuristic WATCH → probabilistic_only flag."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is not None
|
||||||
|
assert output.trade_plan.probabilistic_only is True
|
||||||
|
assert output.trade_plan.dual_confirmed is False
|
||||||
|
|
||||||
|
def test_probabilistic_only_reduced_sizing(self) -> None:
|
||||||
|
"""Probabilistic-only → position_size_pct=0.01 (50% of standard)."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.SKIP, confidence=0.40),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is not None
|
||||||
|
assert output.trade_plan.position_size_pct == 0.01
|
||||||
|
assert output.trade_plan.max_loss_pct == 0.005
|
||||||
|
|
||||||
|
def test_probabilistic_only_price_levels(self) -> None:
|
||||||
|
"""Price levels are the same regardless of confirmation mode."""
|
||||||
|
price = 100.0
|
||||||
|
output = format_output(
|
||||||
|
ticker="MSFT",
|
||||||
|
price=price,
|
||||||
|
heuristic=_heuristic(Verdict.WATCH),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
tp = output.trade_plan
|
||||||
|
assert tp is not None
|
||||||
|
assert tp.entry_price == price
|
||||||
|
assert abs(tp.stop_loss - price * 0.95) < 1e-6
|
||||||
|
assert abs(tp.target_1 - price * 1.05) < 1e-6
|
||||||
|
assert abs(tp.target_2 - price * 1.10) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. Heuristic-only trade plan (Requirement 10.2)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeuristicOnly:
|
||||||
|
"""Heuristic BUY, probabilistic not BUY → standard position sizing."""
|
||||||
|
|
||||||
|
def test_heuristic_only_trade_plan(self) -> None:
|
||||||
|
"""Heuristic BUY + probabilistic WATCH → standard trade plan."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is not None
|
||||||
|
assert output.trade_plan.dual_confirmed is False
|
||||||
|
assert output.trade_plan.probabilistic_only is False
|
||||||
|
|
||||||
|
def test_heuristic_only_full_sizing(self) -> None:
|
||||||
|
"""Heuristic-only → position_size_pct=0.02 (full standard)."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is not None
|
||||||
|
assert output.trade_plan.position_size_pct == 0.02
|
||||||
|
assert output.trade_plan.max_loss_pct == 0.005
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4. No BUY — no trade plan (Requirement 10.4 inverse)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoBuy:
|
||||||
|
"""Neither pipeline BUY → no trade_plan."""
|
||||||
|
|
||||||
|
def test_both_watch_no_trade_plan(self) -> None:
|
||||||
|
"""Both WATCH → no trade_plan."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
|
||||||
|
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is None
|
||||||
|
|
||||||
|
def test_both_skip_no_trade_plan(self) -> None:
|
||||||
|
"""Both SKIP → no trade_plan."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.SKIP, confidence=0.30),
|
||||||
|
probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is None
|
||||||
|
|
||||||
|
def test_watch_and_skip_no_trade_plan(self) -> None:
|
||||||
|
"""WATCH + SKIP → no trade_plan."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
|
||||||
|
probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.trade_plan is None
|
||||||
|
|
||||||
|
def test_no_buy_still_has_pipeline_data(self) -> None:
|
||||||
|
"""Even without trade_plan, pipeline data is populated for analysis."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
|
||||||
|
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.heuristic_verdict == "WATCH"
|
||||||
|
assert output.probabilistic_verdict == "WATCH"
|
||||||
|
assert output.heuristic_confidence == 0.60
|
||||||
|
assert output.probabilistic_p_up == 0.57
|
||||||
|
assert output.delta_agreement is True
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 5. signal_output_to_recommendation mapping (Requirements 12.3, 12.4)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalOutputToRecommendation:
|
||||||
|
"""Map SignalOutput to existing Recommendation schema."""
|
||||||
|
|
||||||
|
def test_dual_confirmed_confidence(self) -> None:
|
||||||
|
"""Dual confirmed → confidence = max(heuristic, probabilistic)."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY, confidence=0.85),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY, p_up=0.75),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.confidence == max(0.85, 0.75)
|
||||||
|
assert rec.confidence == 0.85
|
||||||
|
|
||||||
|
def test_probabilistic_only_confidence_haircut(self) -> None:
|
||||||
|
"""Probabilistic only → confidence = P_up * 0.8 (20% haircut)."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY, p_up=0.75),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert abs(rec.confidence - 0.75 * 0.8) < 1e-9
|
||||||
|
assert abs(rec.confidence - 0.6) < 1e-9
|
||||||
|
|
||||||
|
def test_heuristic_only_confidence(self) -> None:
|
||||||
|
"""Heuristic only → confidence = heuristic_confidence."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY, confidence=0.80),
|
||||||
|
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.confidence == 0.80
|
||||||
|
|
||||||
|
def test_buy_action_mapping(self) -> None:
|
||||||
|
"""Any BUY verdict → ActionType.BUY."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
|
||||||
|
delta=_delta(agreement=False),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.action == ActionType.BUY
|
||||||
|
|
||||||
|
def test_watch_action_mapping(self) -> None:
|
||||||
|
"""Both WATCH → ActionType.WATCH."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
|
||||||
|
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.action == ActionType.WATCH
|
||||||
|
|
||||||
|
def test_skip_action_mapping(self) -> None:
|
||||||
|
"""Both SKIP → ActionType.HOLD."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.SKIP, confidence=0.30),
|
||||||
|
probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.action == ActionType.HOLD
|
||||||
|
|
||||||
|
def test_recommendation_mode_paper_eligible(self) -> None:
|
||||||
|
"""All recommendations use PAPER_ELIGIBLE mode."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.mode == RecommendationMode.PAPER_ELIGIBLE
|
||||||
|
|
||||||
|
def test_recommendation_position_sizing_from_trade_plan(self) -> None:
|
||||||
|
"""Position sizing in recommendation matches trade plan."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.position_sizing.portfolio_pct == 0.02
|
||||||
|
assert rec.position_sizing.max_loss_pct == 0.005
|
||||||
|
|
||||||
|
def test_recommendation_probabilistic_fields(self) -> None:
|
||||||
|
"""Recommendation includes probabilistic pipeline fields."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY, p_up=0.75, ev_r=2.0),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.p_bull == 0.75
|
||||||
|
assert rec.expected_value == 2.0
|
||||||
|
assert rec.pipeline_mode == "dual_pipeline"
|
||||||
|
|
||||||
|
def test_recommendation_ticker_and_id(self) -> None:
|
||||||
|
"""Recommendation inherits ticker and output_id."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="MSFT",
|
||||||
|
price=300.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
rec = signal_output_to_recommendation(output)
|
||||||
|
|
||||||
|
assert rec.ticker == "MSFT"
|
||||||
|
assert rec.recommendation_id == output.output_id
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 6. SignalOutput structure and metadata
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalOutputStructure:
|
||||||
|
"""Verify SignalOutput has all required fields populated."""
|
||||||
|
|
||||||
|
def test_output_has_all_pipeline_data(self) -> None:
|
||||||
|
"""SignalOutput contains heuristic, probabilistic, and delta sections."""
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY, confidence=0.85, s_total=1.5),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY, p_up=0.75, entropy=0.6, ev_r=2.0),
|
||||||
|
delta=_delta(agreement=True, confidence_delta=0.1),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.ticker == "AAPL"
|
||||||
|
assert output.price == 150.0
|
||||||
|
assert output.heuristic_verdict == "BUY"
|
||||||
|
assert output.heuristic_confidence == 0.85
|
||||||
|
assert output.heuristic_s_total == 1.5
|
||||||
|
assert output.probabilistic_verdict == "BUY"
|
||||||
|
assert output.probabilistic_p_up == 0.75
|
||||||
|
assert output.probabilistic_entropy == 0.6
|
||||||
|
assert output.probabilistic_ev_r == 2.0
|
||||||
|
assert output.delta_agreement is True
|
||||||
|
assert output.delta_confidence_delta == 0.1
|
||||||
|
assert output.pipeline_mode == "dual_pipeline"
|
||||||
|
|
||||||
|
def test_output_includes_exit_signals(self) -> None:
|
||||||
|
"""Exit signals are passed through to the output."""
|
||||||
|
exits = [
|
||||||
|
ExitSignal(
|
||||||
|
position_id="pos-1",
|
||||||
|
ticker="AAPL",
|
||||||
|
exit_type=ExitType.EXIT_HALF,
|
||||||
|
reason="target_1_hit",
|
||||||
|
price=157.5,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=exits,
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(output.exit_signals) == 1
|
||||||
|
assert output.exit_signals[0].position_id == "pos-1"
|
||||||
|
assert output.exit_signals[0].reason == "target_1_hit"
|
||||||
|
|
||||||
|
def test_output_shadow_mode(self) -> None:
|
||||||
|
"""Shadow mode flag is propagated from config."""
|
||||||
|
config = SignalEngineConfig(shadow_mode=True)
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=_heuristic(Verdict.BUY),
|
||||||
|
probabilistic=_probabilistic(Verdict.BUY),
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.shadow_mode is True
|
||||||
|
|
||||||
|
def test_output_detail_payloads(self) -> None:
|
||||||
|
"""Heuristic and probabilistic detail payloads are populated for audit."""
|
||||||
|
h = _heuristic(Verdict.BUY, confidence=0.85)
|
||||||
|
p = _probabilistic(Verdict.BUY, p_up=0.75)
|
||||||
|
output = format_output(
|
||||||
|
ticker="AAPL",
|
||||||
|
price=150.0,
|
||||||
|
heuristic=h,
|
||||||
|
probabilistic=p,
|
||||||
|
delta=_delta(agreement=True),
|
||||||
|
exit_signals=[],
|
||||||
|
config=_default_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.heuristic_detail["verdict"] == "BUY"
|
||||||
|
assert output.heuristic_detail["confidence"] == 0.85
|
||||||
|
assert output.probabilistic_detail["verdict"] == "BUY"
|
||||||
|
assert output.probabilistic_detail["p_up"] == 0.75
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Unit tests for the hard filter engine.
|
||||||
|
|
||||||
|
Validates evaluate_hard_filters against requirements 4.1–4.6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.signal_engine.config import HardFilterConfig
|
||||||
|
from services.signal_engine.hard_filter import HardFilterResult, evaluate_hard_filters
|
||||||
|
from services.signal_engine.models import NormalizedInput
|
||||||
|
|
||||||
|
|
||||||
|
def _make_input(**overrides) -> NormalizedInput:
|
||||||
|
"""Build a minimal NormalizedInput with sensible defaults."""
|
||||||
|
defaults = {
|
||||||
|
"ticker": "AAPL",
|
||||||
|
"evaluated_at": datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||||
|
"bars": {},
|
||||||
|
"macro_bias": 0.5,
|
||||||
|
"valuation_score": 0.8,
|
||||||
|
"earnings_proximity_days": 30,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return NormalizedInput(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = HardFilterConfig()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMacroBiasFilter:
|
||||||
|
"""Requirement 4.1: macro_bias == -1.0 → SKIP with reason 'macro_bias_negative'."""
|
||||||
|
|
||||||
|
def test_macro_bias_negative_triggers_filter(self):
|
||||||
|
inp = _make_input(macro_bias=-1.0)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert "macro_bias_negative" in result.reasons
|
||||||
|
|
||||||
|
def test_macro_bias_zero_does_not_trigger(self):
|
||||||
|
inp = _make_input(macro_bias=0.0)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert "macro_bias_negative" not in result.reasons
|
||||||
|
|
||||||
|
def test_macro_bias_positive_does_not_trigger(self):
|
||||||
|
inp = _make_input(macro_bias=0.5)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert "macro_bias_negative" not in result.reasons
|
||||||
|
|
||||||
|
def test_macro_bias_slightly_above_negative_one(self):
|
||||||
|
inp = _make_input(macro_bias=-0.99)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert "macro_bias_negative" not in result.reasons
|
||||||
|
|
||||||
|
|
||||||
|
class TestValuationFilter:
|
||||||
|
"""Requirement 4.2: valuation_score < 0.3 → SKIP with reason 'valuation_below_threshold'."""
|
||||||
|
|
||||||
|
def test_valuation_below_threshold_triggers(self):
|
||||||
|
inp = _make_input(valuation_score=0.1)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert "valuation_below_threshold" in result.reasons
|
||||||
|
|
||||||
|
def test_valuation_at_threshold_does_not_trigger(self):
|
||||||
|
inp = _make_input(valuation_score=0.3)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert "valuation_below_threshold" not in result.reasons
|
||||||
|
|
||||||
|
def test_valuation_above_threshold_does_not_trigger(self):
|
||||||
|
inp = _make_input(valuation_score=0.5)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert "valuation_below_threshold" not in result.reasons
|
||||||
|
|
||||||
|
def test_valuation_none_does_not_trigger(self):
|
||||||
|
"""Missing valuation_score should NOT trigger the filter."""
|
||||||
|
inp = _make_input(valuation_score=None)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert "valuation_below_threshold" not in result.reasons
|
||||||
|
|
||||||
|
|
||||||
|
class TestEarningsFilter:
|
||||||
|
"""Requirement 4.3: earnings_proximity_days <= 5 → SKIP with reason 'earnings_block'."""
|
||||||
|
|
||||||
|
def test_earnings_within_block_triggers(self):
|
||||||
|
inp = _make_input(earnings_proximity_days=3)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert "earnings_block" in result.reasons
|
||||||
|
|
||||||
|
def test_earnings_at_boundary_triggers(self):
|
||||||
|
inp = _make_input(earnings_proximity_days=5)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert "earnings_block" in result.reasons
|
||||||
|
|
||||||
|
def test_earnings_above_boundary_does_not_trigger(self):
|
||||||
|
inp = _make_input(earnings_proximity_days=6)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert "earnings_block" not in result.reasons
|
||||||
|
|
||||||
|
def test_earnings_none_does_not_trigger(self):
|
||||||
|
"""Missing earnings_proximity_days should NOT trigger the filter."""
|
||||||
|
inp = _make_input(earnings_proximity_days=None)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert "earnings_block" not in result.reasons
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleFilters:
|
||||||
|
"""Requirement 4.4: all triggered filter reasons are recorded."""
|
||||||
|
|
||||||
|
def test_all_three_filters_trigger(self):
|
||||||
|
inp = _make_input(
|
||||||
|
macro_bias=-1.0,
|
||||||
|
valuation_score=0.1,
|
||||||
|
earnings_proximity_days=2,
|
||||||
|
)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert len(result.reasons) == 3
|
||||||
|
assert "macro_bias_negative" in result.reasons
|
||||||
|
assert "valuation_below_threshold" in result.reasons
|
||||||
|
assert "earnings_block" in result.reasons
|
||||||
|
|
||||||
|
def test_two_filters_trigger(self):
|
||||||
|
inp = _make_input(
|
||||||
|
macro_bias=-1.0,
|
||||||
|
valuation_score=0.8,
|
||||||
|
earnings_proximity_days=2,
|
||||||
|
)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert len(result.reasons) == 2
|
||||||
|
assert "macro_bias_negative" in result.reasons
|
||||||
|
assert "earnings_block" in result.reasons
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoFilters:
|
||||||
|
"""Requirement 4.5: no hard filters trigger → pass through."""
|
||||||
|
|
||||||
|
def test_clean_input_passes(self):
|
||||||
|
inp = _make_input(
|
||||||
|
macro_bias=0.5,
|
||||||
|
valuation_score=0.8,
|
||||||
|
earnings_proximity_days=30,
|
||||||
|
)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.filtered is False
|
||||||
|
assert result.reasons == []
|
||||||
|
|
||||||
|
def test_all_none_optional_fields_pass(self):
|
||||||
|
"""When optional fields are None, no filters trigger."""
|
||||||
|
inp = _make_input(
|
||||||
|
macro_bias=0.0,
|
||||||
|
valuation_score=None,
|
||||||
|
earnings_proximity_days=None,
|
||||||
|
)
|
||||||
|
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.filtered is False
|
||||||
|
assert result.reasons == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomConfig:
|
||||||
|
"""Verify that the filter respects custom config thresholds."""
|
||||||
|
|
||||||
|
def test_custom_valuation_min(self):
|
||||||
|
config = HardFilterConfig(valuation_min=0.5)
|
||||||
|
inp = _make_input(valuation_score=0.4)
|
||||||
|
result = evaluate_hard_filters(inp, config)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert "valuation_below_threshold" in result.reasons
|
||||||
|
|
||||||
|
def test_custom_earnings_days(self):
|
||||||
|
config = HardFilterConfig(earnings_days=10)
|
||||||
|
inp = _make_input(earnings_proximity_days=8)
|
||||||
|
result = evaluate_hard_filters(inp, config)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert "earnings_block" in result.reasons
|
||||||
|
|
||||||
|
def test_custom_macro_bias_skip(self):
|
||||||
|
config = HardFilterConfig(macro_bias_skip=-0.5)
|
||||||
|
inp = _make_input(macro_bias=-0.5)
|
||||||
|
result = evaluate_hard_filters(inp, config)
|
||||||
|
assert result.filtered is True
|
||||||
|
assert "macro_bias_negative" in result.reasons
|
||||||
|
|
||||||
|
|
||||||
|
class TestHardFilterResultDefaults:
|
||||||
|
"""Verify HardFilterResult dataclass defaults."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
result = HardFilterResult()
|
||||||
|
assert result.filtered is False
|
||||||
|
assert result.reasons == []
|
||||||
|
|
||||||
|
def test_mutable_default_isolation(self):
|
||||||
|
"""Each instance should have its own reasons list."""
|
||||||
|
r1 = HardFilterResult()
|
||||||
|
r2 = HardFilterResult()
|
||||||
|
r1.reasons.append("test")
|
||||||
|
assert r2.reasons == []
|
||||||
@@ -0,0 +1,814 @@
|
|||||||
|
"""Unit tests for services.signal_engine.heuristic — Heuristic Pipeline verdict logic.
|
||||||
|
|
||||||
|
Tests BUY, WATCH, and SKIP verdict conditions, threshold edge cases,
|
||||||
|
confidence computation (agreement boosts, contradiction penalties),
|
||||||
|
S_total computation from multiple signals, and None-valued inputs.
|
||||||
|
|
||||||
|
Requirements: 5.4, 5.5, 5.6
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.signal_engine.config import HeuristicConfig
|
||||||
|
from services.signal_engine.heuristic import (
|
||||||
|
_compute_confidence,
|
||||||
|
_compute_s_company,
|
||||||
|
_compute_s_competitive,
|
||||||
|
_compute_s_macro,
|
||||||
|
_determine_verdict,
|
||||||
|
run_heuristic_pipeline,
|
||||||
|
)
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
ConfluenceSignal,
|
||||||
|
NormalizedInput,
|
||||||
|
SignalDirection,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_NOW = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_config() -> HeuristicConfig:
|
||||||
|
"""Return default heuristic config matching design thresholds."""
|
||||||
|
return HeuristicConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized(
|
||||||
|
*,
|
||||||
|
valuation_score: float | None = 0.8,
|
||||||
|
earnings_proximity_days: int | None = 30,
|
||||||
|
macro_bias: float = 0.5,
|
||||||
|
) -> NormalizedInput:
|
||||||
|
"""Create a NormalizedInput with sensible defaults for testing."""
|
||||||
|
return NormalizedInput(
|
||||||
|
ticker="AAPL",
|
||||||
|
evaluated_at=_NOW,
|
||||||
|
bars={},
|
||||||
|
valuation_score=valuation_score,
|
||||||
|
earnings_proximity_days=earnings_proximity_days,
|
||||||
|
macro_bias=macro_bias,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bullish_signal(
|
||||||
|
signal_type: str = "fibonacci",
|
||||||
|
confluence_score: float = 0.8,
|
||||||
|
timeframes: list[str] | None = None,
|
||||||
|
) -> ConfluenceSignal:
|
||||||
|
"""Create a bullish confluence signal."""
|
||||||
|
tfs = timeframes or ["D", "W"]
|
||||||
|
return ConfluenceSignal(
|
||||||
|
signal_type=signal_type,
|
||||||
|
direction=SignalDirection.BULLISH,
|
||||||
|
confluence_score=confluence_score,
|
||||||
|
active_timeframes=tfs,
|
||||||
|
per_timeframe={tf: confluence_score for tf in tfs},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bearish_signal(
|
||||||
|
signal_type: str = "rsi",
|
||||||
|
confluence_score: float = 0.6,
|
||||||
|
timeframes: list[str] | None = None,
|
||||||
|
) -> ConfluenceSignal:
|
||||||
|
"""Create a bearish confluence signal."""
|
||||||
|
tfs = timeframes or ["D", "H4"]
|
||||||
|
return ConfluenceSignal(
|
||||||
|
signal_type=signal_type,
|
||||||
|
direction=SignalDirection.BEARISH,
|
||||||
|
confluence_score=confluence_score,
|
||||||
|
active_timeframes=tfs,
|
||||||
|
per_timeframe={tf: confluence_score for tf in tfs},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _neutral_signal(
|
||||||
|
signal_type: str = "elliott_wave",
|
||||||
|
confluence_score: float = 0.5,
|
||||||
|
) -> ConfluenceSignal:
|
||||||
|
"""Create a neutral confluence signal."""
|
||||||
|
return ConfluenceSignal(
|
||||||
|
signal_type=signal_type,
|
||||||
|
direction=SignalDirection.NEUTRAL,
|
||||||
|
confluence_score=confluence_score,
|
||||||
|
active_timeframes=["D", "W"],
|
||||||
|
per_timeframe={"D": confluence_score, "W": confluence_score},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. BUY verdict — all conditions met (Requirement 5.4)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuyVerdict:
|
||||||
|
"""BUY requires confidence >= 0.70, S_total >= 1.2, valuation >= 0.5,
|
||||||
|
macro_bias > 0, earnings_proximity_days > 5."""
|
||||||
|
|
||||||
|
def test_buy_all_conditions_met(self) -> None:
|
||||||
|
"""Strong bullish signals + favorable fundamentals → BUY."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.9),
|
||||||
|
_bullish_signal("ma_stack", 0.85),
|
||||||
|
_bullish_signal("rsi", 0.8),
|
||||||
|
]
|
||||||
|
normalized = _normalized(
|
||||||
|
valuation_score=0.7,
|
||||||
|
earnings_proximity_days=30,
|
||||||
|
macro_bias=0.5,
|
||||||
|
)
|
||||||
|
config = _default_config()
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, config)
|
||||||
|
|
||||||
|
assert result.verdict == Verdict.BUY
|
||||||
|
assert result.confidence >= config.buy_confidence
|
||||||
|
assert result.s_total >= config.buy_s_total
|
||||||
|
assert len(result.reasoning) > 0
|
||||||
|
assert "BUY" in result.reasoning[0]
|
||||||
|
|
||||||
|
def test_buy_reasoning_includes_all_values(self) -> None:
|
||||||
|
"""BUY reasoning should mention confidence, S_total, valuation, macro, earnings."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.9),
|
||||||
|
_bullish_signal("ma_stack", 0.85),
|
||||||
|
_bullish_signal("rsi", 0.8),
|
||||||
|
]
|
||||||
|
normalized = _normalized(valuation_score=0.7, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
assert result.verdict == Verdict.BUY
|
||||||
|
reason = result.reasoning[0]
|
||||||
|
assert "confidence" in reason.lower()
|
||||||
|
assert "s_total" in reason.lower()
|
||||||
|
|
||||||
|
def test_buy_s_total_components_populated(self) -> None:
|
||||||
|
"""BUY result should have non-zero s_company and s_macro."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.9),
|
||||||
|
_bullish_signal("ma_stack", 0.85),
|
||||||
|
_bullish_signal("rsi", 0.8),
|
||||||
|
]
|
||||||
|
normalized = _normalized(macro_bias=0.5)
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
assert result.s_company > 0
|
||||||
|
assert result.s_macro > 0 # macro_bias=0.5 * 0.5 weight = 0.25
|
||||||
|
assert result.s_total == result.s_company + result.s_macro + result.s_competitive
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. WATCH verdict — confidence sufficient but BUY conditions not fully met
|
||||||
|
# (Requirement 5.5)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestWatchVerdict:
|
||||||
|
"""WATCH: confidence >= 0.55 but at least one BUY condition fails."""
|
||||||
|
|
||||||
|
def test_watch_low_valuation(self) -> None:
|
||||||
|
"""Confidence OK but valuation below BUY threshold → WATCH."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.85),
|
||||||
|
_bullish_signal("ma_stack", 0.80),
|
||||||
|
]
|
||||||
|
normalized = _normalized(
|
||||||
|
valuation_score=0.3, # below 0.5 BUY threshold
|
||||||
|
macro_bias=0.5,
|
||||||
|
earnings_proximity_days=30,
|
||||||
|
)
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
# Confidence should be >= watch threshold (0.55) with 2 strong bullish signals
|
||||||
|
if result.confidence >= 0.55:
|
||||||
|
assert result.verdict == Verdict.WATCH
|
||||||
|
assert any("WATCH" in r for r in result.reasoning)
|
||||||
|
|
||||||
|
def test_watch_negative_macro_bias(self) -> None:
|
||||||
|
"""Confidence OK but macro_bias <= 0 → WATCH (not BUY)."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.85),
|
||||||
|
_bullish_signal("ma_stack", 0.80),
|
||||||
|
]
|
||||||
|
normalized = _normalized(
|
||||||
|
valuation_score=0.8,
|
||||||
|
macro_bias=-0.1, # negative, fails macro_bias > 0
|
||||||
|
earnings_proximity_days=30,
|
||||||
|
)
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
if result.confidence >= 0.55:
|
||||||
|
assert result.verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_watch_earnings_too_close(self) -> None:
|
||||||
|
"""Confidence OK but earnings within 5 days → WATCH."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.85),
|
||||||
|
_bullish_signal("ma_stack", 0.80),
|
||||||
|
]
|
||||||
|
normalized = _normalized(
|
||||||
|
valuation_score=0.8,
|
||||||
|
macro_bias=0.5,
|
||||||
|
earnings_proximity_days=3, # <= 5, fails earnings condition
|
||||||
|
)
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
if result.confidence >= 0.55:
|
||||||
|
assert result.verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_watch_macro_bias_exactly_zero(self) -> None:
|
||||||
|
"""macro_bias == 0 fails the > 0 condition → WATCH if confidence OK."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.85),
|
||||||
|
_bullish_signal("ma_stack", 0.80),
|
||||||
|
]
|
||||||
|
normalized = _normalized(macro_bias=0.0)
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
if result.confidence >= 0.55:
|
||||||
|
assert result.verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_watch_reasoning_lists_failed_conditions(self) -> None:
|
||||||
|
"""WATCH reasoning should identify which BUY conditions failed."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.85),
|
||||||
|
_bullish_signal("ma_stack", 0.80),
|
||||||
|
]
|
||||||
|
normalized = _normalized(valuation_score=0.3, macro_bias=0.0)
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
if result.verdict == Verdict.WATCH:
|
||||||
|
full_reasoning = " ".join(result.reasoning)
|
||||||
|
assert "valuation" in full_reasoning.lower() or "macro" in full_reasoning.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. SKIP verdict — confidence below watch threshold (Requirement 5.6)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkipVerdict:
|
||||||
|
"""SKIP: confidence < 0.55 (watch threshold)."""
|
||||||
|
|
||||||
|
def test_skip_empty_signals(self) -> None:
|
||||||
|
"""No confluence signals → confidence = 0.0 → SKIP."""
|
||||||
|
normalized = _normalized()
|
||||||
|
result = run_heuristic_pipeline(normalized, [], _default_config())
|
||||||
|
|
||||||
|
assert result.verdict == Verdict.SKIP
|
||||||
|
assert result.confidence == 0.0
|
||||||
|
assert result.s_total == result.s_macro # only macro contributes
|
||||||
|
|
||||||
|
def test_skip_single_weak_signal(self) -> None:
|
||||||
|
"""Single weak signal → low confidence → SKIP."""
|
||||||
|
signals = [_bullish_signal("fibonacci", 0.3)]
|
||||||
|
normalized = _normalized()
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
# Single signal with score 0.3 → base_confidence=0.3, source_factor=0.6
|
||||||
|
# confidence = 0.3 * 0.6 * 1.0 = 0.18 → well below 0.55
|
||||||
|
assert result.verdict == Verdict.SKIP
|
||||||
|
assert result.confidence < 0.55
|
||||||
|
|
||||||
|
def test_skip_reasoning_mentions_threshold(self) -> None:
|
||||||
|
"""SKIP reasoning should reference the watch threshold."""
|
||||||
|
result = run_heuristic_pipeline(_normalized(), [], _default_config())
|
||||||
|
|
||||||
|
assert result.verdict == Verdict.SKIP
|
||||||
|
assert any("SKIP" in r for r in result.reasoning)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4. Edge cases at threshold boundaries
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestThresholdEdgeCases:
|
||||||
|
"""Test behavior at exact threshold values."""
|
||||||
|
|
||||||
|
def test_confidence_exactly_at_buy_threshold(self) -> None:
|
||||||
|
"""Verify _determine_verdict with confidence exactly at 0.70."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, reasoning = _determine_verdict(
|
||||||
|
confidence=0.70,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.BUY
|
||||||
|
|
||||||
|
def test_confidence_just_below_buy_threshold(self) -> None:
|
||||||
|
"""confidence = 0.699 → not BUY, should be WATCH."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.699,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_confidence_exactly_at_watch_threshold(self) -> None:
|
||||||
|
"""confidence = 0.55 → WATCH (not SKIP)."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.55,
|
||||||
|
s_total=0.5, # below BUY s_total threshold
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_confidence_just_below_watch_threshold(self) -> None:
|
||||||
|
"""confidence = 0.549 → SKIP."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized()
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.549,
|
||||||
|
s_total=2.0,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.SKIP
|
||||||
|
|
||||||
|
def test_s_total_exactly_at_buy_threshold(self) -> None:
|
||||||
|
"""S_total = 1.2 exactly → BUY if all other conditions met."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.2,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.BUY
|
||||||
|
|
||||||
|
def test_s_total_just_below_buy_threshold(self) -> None:
|
||||||
|
"""S_total = 1.199 → not BUY, should be WATCH."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.199,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_valuation_exactly_at_buy_threshold(self) -> None:
|
||||||
|
"""valuation_score = 0.5 exactly → BUY if all other conditions met."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.5, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.BUY
|
||||||
|
|
||||||
|
def test_valuation_just_below_buy_threshold(self) -> None:
|
||||||
|
"""valuation_score = 0.499 → not BUY."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.499, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_earnings_exactly_at_threshold(self) -> None:
|
||||||
|
"""earnings_proximity_days = 5 → fails > 5 condition → WATCH."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=5)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_earnings_just_above_threshold(self) -> None:
|
||||||
|
"""earnings_proximity_days = 6 → passes > 5 condition → BUY."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=6)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.BUY
|
||||||
|
|
||||||
|
def test_none_valuation_score_treated_as_zero(self) -> None:
|
||||||
|
"""None valuation_score defaults to 0.0 → fails BUY valuation check."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(valuation_score=None, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_none_earnings_proximity_treated_as_zero(self) -> None:
|
||||||
|
"""None earnings_proximity_days defaults to 0 → fails BUY earnings check."""
|
||||||
|
config = _default_config()
|
||||||
|
normalized = _normalized(
|
||||||
|
valuation_score=0.8,
|
||||||
|
macro_bias=0.5,
|
||||||
|
earnings_proximity_days=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 5. Signal agreement boosts confidence
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfidenceAgreement:
|
||||||
|
"""All signals in the same direction → agreement boost (factor 1.15)."""
|
||||||
|
|
||||||
|
def test_all_bullish_signals_boost_confidence(self) -> None:
|
||||||
|
"""All bullish signals → agreement_factor = 1.15."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.7),
|
||||||
|
_bullish_signal("ma_stack", 0.7),
|
||||||
|
_bullish_signal("rsi", 0.7),
|
||||||
|
]
|
||||||
|
confidence = _compute_confidence(signals)
|
||||||
|
|
||||||
|
# base = 0.7, source_factor = 1 - 0.4/3 ≈ 0.867, agreement = 1.15
|
||||||
|
# confidence = 0.7 * 0.867 * 1.15 ≈ 0.698
|
||||||
|
assert confidence > 0.0
|
||||||
|
# Compare with a mixed-direction set to verify boost
|
||||||
|
mixed_signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.7),
|
||||||
|
_bearish_signal("rsi", 0.7),
|
||||||
|
_bullish_signal("ma_stack", 0.7),
|
||||||
|
]
|
||||||
|
mixed_confidence = _compute_confidence(mixed_signals)
|
||||||
|
assert confidence > mixed_confidence
|
||||||
|
|
||||||
|
def test_all_bearish_signals_boost_confidence(self) -> None:
|
||||||
|
"""All bearish signals → agreement_factor = 1.15 (same boost)."""
|
||||||
|
signals = [
|
||||||
|
_bearish_signal("fibonacci", 0.7),
|
||||||
|
_bearish_signal("ma_stack", 0.7),
|
||||||
|
]
|
||||||
|
confidence = _compute_confidence(signals)
|
||||||
|
|
||||||
|
# base = 0.7, source_factor = 1 - 0.4/2 = 0.8, agreement = 1.15
|
||||||
|
# confidence = 0.7 * 0.8 * 1.15 = 0.644
|
||||||
|
assert confidence > 0.6
|
||||||
|
|
||||||
|
def test_single_signal_no_agreement_factor(self) -> None:
|
||||||
|
"""Single signal → agreement_factor = 1.0 (no boost or penalty)."""
|
||||||
|
signals = [_bullish_signal("fibonacci", 0.8)]
|
||||||
|
confidence = _compute_confidence(signals)
|
||||||
|
|
||||||
|
# base = 0.8, source_factor = 1 - 0.4/1 = 0.6, agreement = 1.0
|
||||||
|
# confidence = 0.8 * 0.6 * 1.0 = 0.48
|
||||||
|
assert abs(confidence - 0.48) < 0.001
|
||||||
|
|
||||||
|
def test_directional_plus_neutral_mild_boost(self) -> None:
|
||||||
|
"""Mix of directional and neutral signals → agreement_factor = 1.05."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.7),
|
||||||
|
_neutral_signal("elliott_wave", 0.7),
|
||||||
|
]
|
||||||
|
confidence = _compute_confidence(signals)
|
||||||
|
|
||||||
|
# base = 0.7, source_factor = 1 - 0.4/2 = 0.8, agreement = 1.05
|
||||||
|
# confidence = 0.7 * 0.8 * 1.05 = 0.588
|
||||||
|
assert abs(confidence - 0.588) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 6. Contradicting signals reduce confidence
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfidenceContradiction:
|
||||||
|
"""Mixed bullish/bearish signals → contradiction penalty."""
|
||||||
|
|
||||||
|
def test_contradiction_reduces_confidence(self) -> None:
|
||||||
|
"""Bullish + bearish signals → penalty proportional to minority fraction."""
|
||||||
|
contradicting = [
|
||||||
|
_bullish_signal("fibonacci", 0.7),
|
||||||
|
_bearish_signal("rsi", 0.7),
|
||||||
|
]
|
||||||
|
agreeing = [
|
||||||
|
_bullish_signal("fibonacci", 0.7),
|
||||||
|
_bullish_signal("rsi", 0.7),
|
||||||
|
]
|
||||||
|
conf_contradicting = _compute_confidence(contradicting)
|
||||||
|
conf_agreeing = _compute_confidence(agreeing)
|
||||||
|
|
||||||
|
assert conf_contradicting < conf_agreeing
|
||||||
|
|
||||||
|
def test_more_contradiction_more_penalty(self) -> None:
|
||||||
|
"""Higher minority fraction → larger penalty."""
|
||||||
|
# 1 bearish out of 3 → minority = 1/3
|
||||||
|
mild_contradiction = [
|
||||||
|
_bullish_signal("fibonacci", 0.7),
|
||||||
|
_bullish_signal("ma_stack", 0.7),
|
||||||
|
_bearish_signal("rsi", 0.7),
|
||||||
|
]
|
||||||
|
# 2 bearish out of 4 → minority = 2/4 = 0.5
|
||||||
|
strong_contradiction = [
|
||||||
|
_bullish_signal("fibonacci", 0.7),
|
||||||
|
_bullish_signal("ma_stack", 0.7),
|
||||||
|
_bearish_signal("rsi", 0.7),
|
||||||
|
_bearish_signal("elliott_wave", 0.7),
|
||||||
|
]
|
||||||
|
conf_mild = _compute_confidence(mild_contradiction)
|
||||||
|
conf_strong = _compute_confidence(strong_contradiction)
|
||||||
|
|
||||||
|
# Strong contradiction should have lower confidence per-signal
|
||||||
|
# (accounting for source count factor difference)
|
||||||
|
# mild: agreement = 1 - 0.3*(1/3) = 0.9
|
||||||
|
# strong: agreement = 1 - 0.3*(2/4) = 0.85
|
||||||
|
# The agreement factor is lower for strong contradiction
|
||||||
|
mild_agreement = 1.0 - 0.3 * (1 / 3)
|
||||||
|
strong_agreement = 1.0 - 0.3 * (2 / 4)
|
||||||
|
assert strong_agreement < mild_agreement
|
||||||
|
|
||||||
|
def test_equal_split_maximum_penalty(self) -> None:
|
||||||
|
"""50/50 split → maximum contradiction penalty (0.3 * 0.5 = 0.15)."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.7),
|
||||||
|
_bearish_signal("rsi", 0.7),
|
||||||
|
]
|
||||||
|
confidence = _compute_confidence(signals)
|
||||||
|
|
||||||
|
# base = 0.7, source_factor = 0.8, agreement = 1 - 0.3*0.5 = 0.85
|
||||||
|
# confidence = 0.7 * 0.8 * 0.85 = 0.476
|
||||||
|
assert abs(confidence - 0.476) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 7. Empty confluence signals → SKIP
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptySignals:
|
||||||
|
"""Empty confluence signals produce confidence = 0 → SKIP."""
|
||||||
|
|
||||||
|
def test_empty_signals_confidence_zero(self) -> None:
|
||||||
|
"""No signals → confidence = 0.0."""
|
||||||
|
assert _compute_confidence([]) == 0.0
|
||||||
|
|
||||||
|
def test_empty_signals_skip_verdict(self) -> None:
|
||||||
|
"""No signals → SKIP regardless of fundamentals."""
|
||||||
|
normalized = _normalized(valuation_score=1.0, macro_bias=1.0, earnings_proximity_days=100)
|
||||||
|
result = run_heuristic_pipeline(normalized, [], _default_config())
|
||||||
|
|
||||||
|
assert result.verdict == Verdict.SKIP
|
||||||
|
assert result.confidence == 0.0
|
||||||
|
|
||||||
|
def test_empty_signals_s_total_only_macro(self) -> None:
|
||||||
|
"""No signals → S_company = 0, S_competitive = 0, S_total = S_macro only."""
|
||||||
|
normalized = _normalized(macro_bias=0.6)
|
||||||
|
result = run_heuristic_pipeline(normalized, [], _default_config())
|
||||||
|
|
||||||
|
assert result.s_company == 0.0
|
||||||
|
assert result.s_competitive == 0.0
|
||||||
|
assert result.s_macro == 0.6 * 0.5 # macro_bias * _MACRO_WEIGHT
|
||||||
|
assert result.s_total == result.s_macro
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 8. S_total computation from multiple signals
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSTotalComputation:
|
||||||
|
"""S_total = S_company + S_macro + S_competitive."""
|
||||||
|
|
||||||
|
def test_s_company_sums_company_signals(self) -> None:
|
||||||
|
"""Company-level signals (fibonacci, ma_stack, rsi) contribute to S_company."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.5),
|
||||||
|
_bullish_signal("ma_stack", 0.3),
|
||||||
|
]
|
||||||
|
s_company, weights = _compute_s_company(signals)
|
||||||
|
|
||||||
|
# Both bullish → positive contributions
|
||||||
|
assert s_company == 0.5 + 0.3
|
||||||
|
assert len(weights) == 2
|
||||||
|
assert all(w["layer"] == "company" for w in weights)
|
||||||
|
|
||||||
|
def test_s_company_bearish_signals_subtract(self) -> None:
|
||||||
|
"""Bearish company signals contribute negatively to S_company."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.5),
|
||||||
|
_bearish_signal("rsi", 0.3),
|
||||||
|
]
|
||||||
|
s_company, weights = _compute_s_company(signals)
|
||||||
|
|
||||||
|
# fibonacci: +0.5, rsi: -0.3
|
||||||
|
assert abs(s_company - 0.2) < 0.001
|
||||||
|
|
||||||
|
def test_s_company_neutral_signals_zero_contribution(self) -> None:
|
||||||
|
"""Neutral company signals contribute 0 to S_company."""
|
||||||
|
signals = [
|
||||||
|
ConfluenceSignal(
|
||||||
|
signal_type="fibonacci",
|
||||||
|
direction=SignalDirection.NEUTRAL,
|
||||||
|
confluence_score=0.8,
|
||||||
|
active_timeframes=["D", "W"],
|
||||||
|
per_timeframe={"D": 0.8, "W": 0.8},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
s_company, _ = _compute_s_company(signals)
|
||||||
|
assert s_company == 0.0
|
||||||
|
|
||||||
|
def test_s_company_ignores_non_company_signals(self) -> None:
|
||||||
|
"""Signals not in COMPANY_SIGNAL_TYPES are ignored for S_company."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("unknown_signal_type", 0.9),
|
||||||
|
]
|
||||||
|
s_company, weights = _compute_s_company(signals)
|
||||||
|
|
||||||
|
assert s_company == 0.0
|
||||||
|
assert len(weights) == 0
|
||||||
|
|
||||||
|
def test_s_macro_positive_bias(self) -> None:
|
||||||
|
"""Positive macro_bias → positive S_macro."""
|
||||||
|
normalized = _normalized(macro_bias=0.8)
|
||||||
|
s_macro = _compute_s_macro(normalized)
|
||||||
|
assert s_macro == 0.8 * 0.5 # macro_bias * _MACRO_WEIGHT
|
||||||
|
|
||||||
|
def test_s_macro_negative_bias(self) -> None:
|
||||||
|
"""Negative macro_bias → negative S_macro."""
|
||||||
|
normalized = _normalized(macro_bias=-0.6)
|
||||||
|
s_macro = _compute_s_macro(normalized)
|
||||||
|
assert s_macro == -0.6 * 0.5
|
||||||
|
|
||||||
|
def test_s_macro_zero_bias(self) -> None:
|
||||||
|
"""Zero macro_bias → zero S_macro."""
|
||||||
|
normalized = _normalized(macro_bias=0.0)
|
||||||
|
s_macro = _compute_s_macro(normalized)
|
||||||
|
assert s_macro == 0.0
|
||||||
|
|
||||||
|
def test_s_competitive_currently_zero(self) -> None:
|
||||||
|
"""No competitive signal types defined → S_competitive = 0."""
|
||||||
|
signals = [_bullish_signal("fibonacci", 0.9)]
|
||||||
|
s_competitive = _compute_s_competitive(signals)
|
||||||
|
assert s_competitive == 0.0
|
||||||
|
|
||||||
|
def test_s_total_is_sum_of_components(self) -> None:
|
||||||
|
"""S_total = S_company + S_macro + S_competitive."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.5),
|
||||||
|
_bullish_signal("ma_stack", 0.4),
|
||||||
|
]
|
||||||
|
normalized = _normalized(macro_bias=0.6)
|
||||||
|
result = run_heuristic_pipeline(normalized, signals, _default_config())
|
||||||
|
|
||||||
|
expected_s_company = 0.5 + 0.4
|
||||||
|
expected_s_macro = 0.6 * 0.5
|
||||||
|
expected_s_competitive = 0.0
|
||||||
|
expected_s_total = expected_s_company + expected_s_macro + expected_s_competitive
|
||||||
|
|
||||||
|
assert abs(result.s_company - expected_s_company) < 0.001
|
||||||
|
assert abs(result.s_macro - expected_s_macro) < 0.001
|
||||||
|
assert abs(result.s_competitive - expected_s_competitive) < 0.001
|
||||||
|
assert abs(result.s_total - expected_s_total) < 0.001
|
||||||
|
|
||||||
|
def test_signal_weights_audit_trail(self) -> None:
|
||||||
|
"""signal_weights list contains per-signal audit info."""
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 0.5),
|
||||||
|
_bullish_signal("rsi", 0.3),
|
||||||
|
]
|
||||||
|
result = run_heuristic_pipeline(_normalized(), signals, _default_config())
|
||||||
|
|
||||||
|
assert len(result.signal_weights) == 2
|
||||||
|
types = {w["signal_type"] for w in result.signal_weights}
|
||||||
|
assert "fibonacci" in types
|
||||||
|
assert "rsi" in types
|
||||||
|
for w in result.signal_weights:
|
||||||
|
assert "contribution" in w
|
||||||
|
assert "direction" in w
|
||||||
|
assert "active_timeframes" in w
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 9. Full pipeline integration — HeuristicResult structure
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeuristicResultStructure:
|
||||||
|
"""Verify the HeuristicResult has all required fields."""
|
||||||
|
|
||||||
|
def test_result_has_all_fields(self) -> None:
|
||||||
|
"""HeuristicResult contains verdict, confidence, scores, weights, reasoning."""
|
||||||
|
signals = [_bullish_signal("fibonacci", 0.7)]
|
||||||
|
result = run_heuristic_pipeline(_normalized(), signals, _default_config())
|
||||||
|
|
||||||
|
assert result.verdict in (Verdict.BUY, Verdict.WATCH, Verdict.SKIP)
|
||||||
|
assert 0.0 <= result.confidence <= 1.0
|
||||||
|
assert isinstance(result.s_total, float)
|
||||||
|
assert isinstance(result.s_company, float)
|
||||||
|
assert isinstance(result.s_macro, float)
|
||||||
|
assert isinstance(result.s_competitive, float)
|
||||||
|
assert isinstance(result.signal_weights, list)
|
||||||
|
assert isinstance(result.reasoning, list)
|
||||||
|
assert len(result.reasoning) > 0
|
||||||
|
|
||||||
|
def test_confidence_clamped_to_unit_interval(self) -> None:
|
||||||
|
"""Confidence is always in [0.0, 1.0] even with strong agreement boost."""
|
||||||
|
# Very high confluence scores with perfect agreement
|
||||||
|
signals = [
|
||||||
|
_bullish_signal("fibonacci", 1.0),
|
||||||
|
_bullish_signal("ma_stack", 1.0),
|
||||||
|
_bullish_signal("rsi", 1.0),
|
||||||
|
_bullish_signal("cup_handle", 1.0),
|
||||||
|
_bullish_signal("elliott_wave", 1.0),
|
||||||
|
]
|
||||||
|
confidence = _compute_confidence(signals)
|
||||||
|
assert 0.0 <= confidence <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 10. Custom config thresholds
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomConfig:
|
||||||
|
"""Verify that custom config thresholds are respected."""
|
||||||
|
|
||||||
|
def test_custom_buy_confidence_threshold(self) -> None:
|
||||||
|
"""Lowering buy_confidence makes BUY easier to achieve."""
|
||||||
|
config = HeuristicConfig(buy_confidence=0.50, buy_s_total=0.5)
|
||||||
|
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.55,
|
||||||
|
s_total=0.8,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.BUY
|
||||||
|
|
||||||
|
def test_custom_watch_confidence_threshold(self) -> None:
|
||||||
|
"""Raising watch_confidence makes WATCH harder to achieve."""
|
||||||
|
config = HeuristicConfig(watch_confidence=0.80)
|
||||||
|
normalized = _normalized()
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.75,
|
||||||
|
s_total=0.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.SKIP # 0.75 < 0.80 watch threshold
|
||||||
|
|
||||||
|
def test_custom_earnings_threshold(self) -> None:
|
||||||
|
"""Custom earnings_days_threshold changes BUY gating."""
|
||||||
|
config = HeuristicConfig(earnings_days_threshold=10)
|
||||||
|
normalized = _normalized(
|
||||||
|
valuation_score=0.8,
|
||||||
|
macro_bias=0.5,
|
||||||
|
earnings_proximity_days=8, # > 5 default but <= 10 custom
|
||||||
|
)
|
||||||
|
|
||||||
|
verdict, _ = _determine_verdict(
|
||||||
|
confidence=0.80,
|
||||||
|
s_total=1.5,
|
||||||
|
normalized=normalized,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
assert verdict == Verdict.WATCH # 8 is not > 10
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Unit tests for services.signal_engine.signals.ma_stack — Moving average stack evaluator.
|
||||||
|
|
||||||
|
Requirements: 2.2, 2.6, 2.7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection
|
||||||
|
from services.signal_engine.signals.ma_stack import (
|
||||||
|
MA_PERIODS,
|
||||||
|
MIN_BARS,
|
||||||
|
MAStackEvaluator,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(close: float) -> OHLCVBar:
|
||||||
|
"""Create a minimal OHLCVBar for testing."""
|
||||||
|
return OHLCVBar(
|
||||||
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
open=close,
|
||||||
|
high=close,
|
||||||
|
low=close,
|
||||||
|
close=close,
|
||||||
|
volume=1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bars(n: int, close: float = 100.0) -> list[OHLCVBar]:
|
||||||
|
"""Create *n* identical bars with the same close price."""
|
||||||
|
return [_bar(close) for _ in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def _trending_bars(n: int, start: float, step: float) -> list[OHLCVBar]:
|
||||||
|
"""Create *n* bars with linearly increasing/decreasing close prices.
|
||||||
|
|
||||||
|
``start`` is the first bar's close; each subsequent bar adds ``step``.
|
||||||
|
"""
|
||||||
|
return [_bar(start + i * step) for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ma_periods_constant() -> None:
|
||||||
|
assert MA_PERIODS == [10, 20, 50, 200]
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_bars_constant() -> None:
|
||||||
|
assert MIN_BARS == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Insufficient data → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_insufficient_bars() -> None:
|
||||||
|
"""Requirement 2.6: return None when fewer bars than 200."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
bars = _make_bars(199)
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_empty_bars() -> None:
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
assert evaluator.evaluate([], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_one_bar() -> None:
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
assert evaluator.evaluate([_bar(100.0)], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# No alignment → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_all_mas_equal() -> None:
|
||||||
|
"""When all bars have the same close, all MAs are equal — no alignment."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
bars = _make_bars(200, close=100.0)
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Full bullish alignment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_bullish_alignment() -> None:
|
||||||
|
"""Requirement 2.2: MA_10 > MA_20 > MA_50 > MA_200 → bullish, strength 1.0."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
# Strongly uptrending: recent prices much higher than old prices
|
||||||
|
# This ensures MA_10 > MA_20 > MA_50 > MA_200
|
||||||
|
bars = _trending_bars(200, start=50.0, step=1.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "ma_stack"
|
||||||
|
assert result.direction == SignalDirection.BULLISH
|
||||||
|
assert result.strength == 1.0
|
||||||
|
assert result.confidence == 1.0 * 0.9
|
||||||
|
assert result.metadata["alignment"] == "full_bullish"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Full bearish alignment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_bearish_alignment() -> None:
|
||||||
|
"""Requirement 2.2: MA_10 < MA_20 < MA_50 < MA_200 → bearish, strength 1.0."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
# Strongly downtrending: recent prices much lower than old prices
|
||||||
|
bars = _trending_bars(200, start=250.0, step=-1.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "ma_stack"
|
||||||
|
assert result.direction == SignalDirection.BEARISH
|
||||||
|
assert result.strength == 1.0
|
||||||
|
assert result.confidence == 1.0 * 0.9
|
||||||
|
assert result.metadata["alignment"] == "full_bearish"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Partial bullish alignment (3/4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_bullish_alignment() -> None:
|
||||||
|
"""3 out of 4 MAs in bullish order → strength 0.6."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
# Build bars where MA_10 > MA_20 > MA_50 but MA_50 < MA_200
|
||||||
|
# Use flat early prices (high MA_200) then a moderate uptrend at the end
|
||||||
|
bars = _make_bars(150, close=200.0) + _trending_bars(50, start=100.0, step=2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
# The recent uptrend should give MA_10 > MA_20 > MA_50
|
||||||
|
# but MA_200 includes the high early prices, so MA_50 < MA_200
|
||||||
|
if result is not None:
|
||||||
|
assert result.direction == SignalDirection.BULLISH
|
||||||
|
assert result.strength == 0.6
|
||||||
|
assert result.confidence == 0.6 * 0.9
|
||||||
|
assert result.metadata["alignment"] == "partial_bullish"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Partial bearish alignment (3/4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_bearish_alignment() -> None:
|
||||||
|
"""3 out of 4 MAs in bearish order → strength 0.6."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
# Build bars where MA_10 < MA_20 < MA_50 but MA_50 > MA_200
|
||||||
|
# Use flat low early prices (low MA_200) then a moderate downtrend at the end
|
||||||
|
bars = _make_bars(150, close=50.0) + _trending_bars(50, start=200.0, step=-2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
if result is not None:
|
||||||
|
assert result.direction == SignalDirection.BEARISH
|
||||||
|
assert result.strength == 0.6
|
||||||
|
assert result.confidence == 0.6 * 0.9
|
||||||
|
assert result.metadata["alignment"] == "partial_bearish"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal result structure (Requirement 2.7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_result_structure() -> None:
|
||||||
|
"""Requirement 2.7: SignalResult has all required fields."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
bars = _trending_bars(200, start=50.0, step=1.0)
|
||||||
|
result = evaluator.evaluate(bars, "H4")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "ma_stack"
|
||||||
|
assert result.timeframe == "H4"
|
||||||
|
assert 0.0 <= result.strength <= 1.0
|
||||||
|
assert 0.0 <= result.confidence <= 1.0
|
||||||
|
assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_contains_all_ma_values() -> None:
|
||||||
|
"""Metadata should include all four MA values and alignment type."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
bars = _trending_bars(200, start=50.0, step=1.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
meta = result.metadata
|
||||||
|
assert "ma_10" in meta
|
||||||
|
assert "ma_20" in meta
|
||||||
|
assert "ma_50" in meta
|
||||||
|
assert "ma_200" in meta
|
||||||
|
assert "alignment" in meta
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timeframe passthrough
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeframe_passthrough() -> None:
|
||||||
|
"""The timeframe label is passed through to the result."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
bars = _trending_bars(200, start=50.0, step=1.0)
|
||||||
|
for tf in ("M30", "H1", "H4", "D", "W", "M"):
|
||||||
|
result = evaluator.evaluate(bars, tf)
|
||||||
|
assert result is not None
|
||||||
|
assert result.timeframe == tf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exactly 200 bars (boundary)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_200_bars_works() -> None:
|
||||||
|
"""Exactly 200 bars should be sufficient (boundary condition)."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
bars = _trending_bars(200, start=50.0, step=1.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_199_bars_returns_none() -> None:
|
||||||
|
"""199 bars is insufficient."""
|
||||||
|
evaluator = MAStackEvaluator()
|
||||||
|
bars = _trending_bars(199, start=50.0, step=1.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is None
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
"""Unit tests for services.signal_engine.normalizer.
|
||||||
|
|
||||||
|
Tests the input normalizer's data assembly, sentinel handling, timestamp
|
||||||
|
validation, and derived field computation.
|
||||||
|
|
||||||
|
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.signal_engine.config import SignalEngineConfig
|
||||||
|
from services.signal_engine.models import OHLCVBar
|
||||||
|
from services.signal_engine.normalizer import (
|
||||||
|
TIMEFRAMES,
|
||||||
|
_aggregate_bars_by_period,
|
||||||
|
_polygon_bar_to_ohlcv,
|
||||||
|
_validate_monotonic_timestamps,
|
||||||
|
normalize_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_bar_row(ts_ms: int, o: float, h: float, l: float, c: float, v: float) -> MagicMock:
|
||||||
|
"""Create a mock asyncpg.Record with Polygon bar data."""
|
||||||
|
row = MagicMock()
|
||||||
|
row.__getitem__ = lambda self, key: {
|
||||||
|
"data": {"t": ts_ms, "o": o, "h": h, "l": l, "c": c, "v": v},
|
||||||
|
}[key]
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bar(ts_ms: int, c: float = 100.0) -> OHLCVBar:
|
||||||
|
"""Create an OHLCVBar with a given timestamp and close price."""
|
||||||
|
return OHLCVBar(
|
||||||
|
timestamp=datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc),
|
||||||
|
open=c - 1,
|
||||||
|
high=c + 1,
|
||||||
|
low=c - 2,
|
||||||
|
close=c,
|
||||||
|
volume=1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _polygon_bar_to_ohlcv
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPolygonBarToOhlcv:
|
||||||
|
def test_valid_bar(self):
|
||||||
|
row = _make_bar_row(1700000000000, 100.0, 105.0, 99.0, 103.0, 5000.0)
|
||||||
|
bar = _polygon_bar_to_ohlcv(row)
|
||||||
|
assert bar is not None
|
||||||
|
assert bar.open == 100.0
|
||||||
|
assert bar.high == 105.0
|
||||||
|
assert bar.low == 99.0
|
||||||
|
assert bar.close == 103.0
|
||||||
|
assert bar.volume == 5000.0
|
||||||
|
assert bar.timestamp.year == 2023
|
||||||
|
|
||||||
|
def test_missing_timestamp_returns_none(self):
|
||||||
|
row = MagicMock()
|
||||||
|
row.__getitem__ = lambda self, key: {"data": {"o": 1, "h": 2, "l": 0, "c": 1, "v": 10}}[key]
|
||||||
|
assert _polygon_bar_to_ohlcv(row) is None
|
||||||
|
|
||||||
|
def test_non_dict_data_returns_none(self):
|
||||||
|
row = MagicMock()
|
||||||
|
row.__getitem__ = lambda self, key: {"data": "not a dict"}[key]
|
||||||
|
assert _polygon_bar_to_ohlcv(row) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _validate_monotonic_timestamps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateMonotonicTimestamps:
|
||||||
|
def test_already_monotonic(self):
|
||||||
|
bars = [_make_bar(1000 * i) for i in [1000, 2000, 3000]]
|
||||||
|
result = _validate_monotonic_timestamps(bars, "D", "AAPL")
|
||||||
|
assert result is bars # same reference — no sorting needed
|
||||||
|
|
||||||
|
def test_non_monotonic_gets_sorted(self):
|
||||||
|
bars = [_make_bar(1000 * i) for i in [3000, 1000, 2000]]
|
||||||
|
result = _validate_monotonic_timestamps(bars, "D", "AAPL")
|
||||||
|
timestamps = [b.timestamp for b in result]
|
||||||
|
assert timestamps == sorted(timestamps)
|
||||||
|
|
||||||
|
def test_single_bar(self):
|
||||||
|
bars = [_make_bar(1000000)]
|
||||||
|
result = _validate_monotonic_timestamps(bars, "D", "AAPL")
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
result = _validate_monotonic_timestamps([], "D", "AAPL")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _aggregate_bars_by_period
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAggregate:
|
||||||
|
def test_weekly_aggregation(self):
|
||||||
|
# Create 10 daily bars spanning ~2 weeks
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
base = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||||
|
daily = []
|
||||||
|
for i in range(10):
|
||||||
|
ts = base + timedelta(days=i)
|
||||||
|
daily.append(
|
||||||
|
OHLCVBar(
|
||||||
|
timestamp=ts,
|
||||||
|
open=100.0 + i,
|
||||||
|
high=110.0 + i,
|
||||||
|
low=90.0 + i,
|
||||||
|
close=105.0 + i,
|
||||||
|
volume=1000.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
weekly = _aggregate_bars_by_period(daily, "week")
|
||||||
|
assert len(weekly) >= 2 # should span at least 2 ISO weeks
|
||||||
|
# Each weekly bar should have correct OHLCV aggregation
|
||||||
|
for w in weekly:
|
||||||
|
assert w.volume >= 1000.0 # at least one day's volume
|
||||||
|
|
||||||
|
def test_monthly_aggregation(self):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
base = datetime(2024, 1, 15, tzinfo=timezone.utc)
|
||||||
|
daily = []
|
||||||
|
for i in range(45): # spans Jan and Feb
|
||||||
|
ts = base + timedelta(days=i)
|
||||||
|
daily.append(
|
||||||
|
OHLCVBar(
|
||||||
|
timestamp=ts,
|
||||||
|
open=100.0,
|
||||||
|
high=110.0,
|
||||||
|
low=90.0,
|
||||||
|
close=105.0,
|
||||||
|
volume=500.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
monthly = _aggregate_bars_by_period(daily, "month")
|
||||||
|
assert len(monthly) >= 2 # should span at least 2 months
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
assert _aggregate_bars_by_period([], "week") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_input — integration with mocked DB
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeInput:
|
||||||
|
"""Test the full normalize_input function with mocked asyncpg pool."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(self):
|
||||||
|
return SignalEngineConfig()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_pool(self):
|
||||||
|
pool = AsyncMock()
|
||||||
|
return pool
|
||||||
|
|
||||||
|
def _setup_pool_with_data(self, pool):
|
||||||
|
"""Configure the mock pool to return realistic data."""
|
||||||
|
ts_base = 1700000000000 # Nov 2023
|
||||||
|
|
||||||
|
# Daily bars
|
||||||
|
daily_rows = []
|
||||||
|
for i in range(5):
|
||||||
|
row = MagicMock()
|
||||||
|
data = {
|
||||||
|
"t": ts_base + i * 86400000,
|
||||||
|
"o": 100.0 + i,
|
||||||
|
"h": 105.0 + i,
|
||||||
|
"l": 98.0 + i,
|
||||||
|
"c": 103.0 + i,
|
||||||
|
"v": 10000.0,
|
||||||
|
}
|
||||||
|
row.__getitem__ = lambda self, key, d=data: {"data": d}[key]
|
||||||
|
daily_rows.append(row)
|
||||||
|
|
||||||
|
# Trend window row
|
||||||
|
trend_row = MagicMock()
|
||||||
|
trend_row.__getitem__ = lambda self, key: {"confidence": 0.75}[key]
|
||||||
|
|
||||||
|
# Earnings row
|
||||||
|
from datetime import date, timedelta
|
||||||
|
future_date = date.today() + timedelta(days=30)
|
||||||
|
earnings_row = MagicMock()
|
||||||
|
earnings_row.__getitem__ = lambda self, key: {"earnings_date": future_date}[key]
|
||||||
|
|
||||||
|
# Macro impact rows
|
||||||
|
macro_rows = []
|
||||||
|
for direction in ["positive", "positive", "negative"]:
|
||||||
|
row = MagicMock()
|
||||||
|
row.__getitem__ = lambda self, key, d=direction: {
|
||||||
|
"impact_direction": d,
|
||||||
|
"macro_impact_score": 0.5,
|
||||||
|
"confidence": 0.8,
|
||||||
|
}[key]
|
||||||
|
macro_rows.append(row)
|
||||||
|
|
||||||
|
# Position rows — empty
|
||||||
|
position_rows = []
|
||||||
|
|
||||||
|
# Configure pool.fetch / pool.fetchrow responses
|
||||||
|
call_count = {"fetch": 0, "fetchrow": 0}
|
||||||
|
|
||||||
|
async def mock_fetch(query, *args):
|
||||||
|
q = query.strip().lower()
|
||||||
|
if "market_snapshots" in q and "snapshot_type = 'bar'" in q:
|
||||||
|
return daily_rows
|
||||||
|
if "market_snapshots" in q and "intraday_bar" in q:
|
||||||
|
return []
|
||||||
|
if "macro_impact_records" in q:
|
||||||
|
return macro_rows
|
||||||
|
if "position_stop_levels" in q:
|
||||||
|
return position_rows
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def mock_fetchrow(query, *args):
|
||||||
|
q = query.strip().lower()
|
||||||
|
if "trend_windows" in q:
|
||||||
|
return trend_row
|
||||||
|
if "earnings_calendar" in q:
|
||||||
|
return earnings_row
|
||||||
|
return None
|
||||||
|
|
||||||
|
pool.fetch = mock_fetch
|
||||||
|
pool.fetchrow = mock_fetchrow
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_normalization(self, mock_pool, config):
|
||||||
|
self._setup_pool_with_data(mock_pool)
|
||||||
|
result = await normalize_input(mock_pool, "AAPL", config)
|
||||||
|
|
||||||
|
assert result.ticker == "AAPL"
|
||||||
|
assert result.evaluated_at is not None
|
||||||
|
assert len(result.bars["D"]) == 5
|
||||||
|
assert result.valuation_score == 0.75
|
||||||
|
assert result.earnings_proximity_days is not None
|
||||||
|
assert result.earnings_proximity_days > 0
|
||||||
|
assert result.macro_bias != 0.0 # should be positive-leaning
|
||||||
|
assert result.open_positions == []
|
||||||
|
assert len(result.closing_prices) == 5
|
||||||
|
assert len(result.returns) == 4 # n-1 returns
|
||||||
|
assert result.current_price is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sentinel_values_on_empty_data(self, mock_pool, config):
|
||||||
|
"""When all data sources return empty, sentinels are used."""
|
||||||
|
async def empty_fetch(query, *args):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def empty_fetchrow(query, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_pool.fetch = empty_fetch
|
||||||
|
mock_pool.fetchrow = empty_fetchrow
|
||||||
|
|
||||||
|
result = await normalize_input(mock_pool, "UNKNOWN", config)
|
||||||
|
|
||||||
|
assert result.ticker == "UNKNOWN"
|
||||||
|
assert all(result.bars[tf] == [] for tf in TIMEFRAMES)
|
||||||
|
assert result.valuation_score is None
|
||||||
|
assert result.earnings_proximity_days is None
|
||||||
|
assert result.macro_bias == 0.0
|
||||||
|
assert result.open_positions == []
|
||||||
|
assert result.closing_prices == []
|
||||||
|
assert result.returns == []
|
||||||
|
assert result.current_price is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_errors_produce_sentinels(self, mock_pool, config):
|
||||||
|
"""When DB queries raise exceptions, sentinels are used."""
|
||||||
|
async def failing_fetch(query, *args):
|
||||||
|
raise Exception("DB connection lost")
|
||||||
|
|
||||||
|
async def failing_fetchrow(query, *args):
|
||||||
|
raise Exception("DB connection lost")
|
||||||
|
|
||||||
|
mock_pool.fetch = failing_fetch
|
||||||
|
mock_pool.fetchrow = failing_fetchrow
|
||||||
|
|
||||||
|
result = await normalize_input(mock_pool, "FAIL", config)
|
||||||
|
|
||||||
|
assert result.ticker == "FAIL"
|
||||||
|
assert all(result.bars[tf] == [] for tf in TIMEFRAMES)
|
||||||
|
assert result.valuation_score is None
|
||||||
|
assert result.earnings_proximity_days is None
|
||||||
|
assert result.macro_bias == 0.0
|
||||||
|
assert result.open_positions == []
|
||||||
|
assert result.current_price is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_weekly_monthly_derived_from_daily(self, mock_pool, config):
|
||||||
|
"""Weekly and monthly bars are derived from daily bars."""
|
||||||
|
ts_base = 1700000000000
|
||||||
|
daily_rows = []
|
||||||
|
for i in range(30): # 30 days of data
|
||||||
|
row = MagicMock()
|
||||||
|
data = {
|
||||||
|
"t": ts_base + i * 86400000,
|
||||||
|
"o": 100.0,
|
||||||
|
"h": 110.0,
|
||||||
|
"l": 90.0,
|
||||||
|
"c": 105.0,
|
||||||
|
"v": 1000.0,
|
||||||
|
}
|
||||||
|
row.__getitem__ = lambda self, key, d=data: {"data": d}[key]
|
||||||
|
daily_rows.append(row)
|
||||||
|
|
||||||
|
async def mock_fetch(query, *args):
|
||||||
|
q = query.strip().lower()
|
||||||
|
if "market_snapshots" in q and "snapshot_type = 'bar'" in q:
|
||||||
|
return daily_rows
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def mock_fetchrow(query, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_pool.fetch = mock_fetch
|
||||||
|
mock_pool.fetchrow = mock_fetchrow
|
||||||
|
|
||||||
|
result = await normalize_input(mock_pool, "AAPL", config)
|
||||||
|
|
||||||
|
assert len(result.bars["D"]) == 30
|
||||||
|
assert len(result.bars["W"]) > 0 # weekly derived
|
||||||
|
assert len(result.bars["M"]) > 0 # monthly derived
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_current_price_from_shortest_timeframe(self, mock_pool, config):
|
||||||
|
"""current_price comes from the shortest available timeframe."""
|
||||||
|
ts_base = 1700000000000
|
||||||
|
|
||||||
|
# Only provide intraday bars (M30), no daily
|
||||||
|
intraday_rows = []
|
||||||
|
for i in range(3):
|
||||||
|
row = MagicMock()
|
||||||
|
data = {
|
||||||
|
"t": ts_base + i * 1800000, # 30-min intervals
|
||||||
|
"o": 100.0,
|
||||||
|
"h": 110.0,
|
||||||
|
"l": 90.0,
|
||||||
|
"c": 150.0 + i, # last close = 152.0
|
||||||
|
"v": 500.0,
|
||||||
|
}
|
||||||
|
row.__getitem__ = lambda self, key, d=data: {"data": d}[key]
|
||||||
|
intraday_rows.append(row)
|
||||||
|
|
||||||
|
async def mock_fetch(query, *args):
|
||||||
|
q = query.strip().lower()
|
||||||
|
if "intraday_bar" in q:
|
||||||
|
return intraday_rows
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def mock_fetchrow(query, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_pool.fetch = mock_fetch
|
||||||
|
mock_pool.fetchrow = mock_fetchrow
|
||||||
|
|
||||||
|
result = await normalize_input(mock_pool, "AAPL", config)
|
||||||
|
|
||||||
|
# M30 is the shortest timeframe and has data
|
||||||
|
assert result.current_price == 152.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_macro_bias_computation(self, mock_pool, config):
|
||||||
|
"""macro_bias is a weighted average of direction scores."""
|
||||||
|
macro_rows = []
|
||||||
|
# 2 positive, 1 negative — should lean positive
|
||||||
|
for direction, score, conf in [
|
||||||
|
("positive", 0.8, 0.9),
|
||||||
|
("positive", 0.6, 0.7),
|
||||||
|
("negative", 0.3, 0.5),
|
||||||
|
]:
|
||||||
|
row = MagicMock()
|
||||||
|
row.__getitem__ = lambda self, key, d=direction, s=score, c=conf: {
|
||||||
|
"impact_direction": d,
|
||||||
|
"macro_impact_score": s,
|
||||||
|
"confidence": c,
|
||||||
|
}[key]
|
||||||
|
macro_rows.append(row)
|
||||||
|
|
||||||
|
async def mock_fetch(query, *args):
|
||||||
|
if "macro_impact_records" in query:
|
||||||
|
return macro_rows
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def mock_fetchrow(query, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_pool.fetch = mock_fetch
|
||||||
|
mock_pool.fetchrow = mock_fetchrow
|
||||||
|
|
||||||
|
result = await normalize_input(mock_pool, "AAPL", config)
|
||||||
|
|
||||||
|
# Weighted: pos(0.8*0.9=0.72) + pos(0.6*0.7=0.42) + neg(0.3*0.5=0.15)
|
||||||
|
# = (1.0*0.72 + 1.0*0.42 + (-1.0)*0.15) / (0.72+0.42+0.15)
|
||||||
|
# = (0.72 + 0.42 - 0.15) / 1.29 ≈ 0.767
|
||||||
|
assert result.macro_bias > 0.0
|
||||||
|
assert result.macro_bias <= 1.0
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""Unit tests for the probabilistic (Bayesian) pipeline.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Regime-to-prior mapping
|
||||||
|
- Likelihood ratio computation
|
||||||
|
- Log-odds accumulation and sigmoid round-trip
|
||||||
|
- Shannon entropy computation
|
||||||
|
- Entropy gating (SKIP on high entropy)
|
||||||
|
- EV_R computation
|
||||||
|
- BUY / WATCH / SKIP verdict thresholds
|
||||||
|
- Edge cases (no signals, boundary values)
|
||||||
|
|
||||||
|
Requirements: 6.1–6.9, 14.1–14.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.aggregation.regime import MarketRegime, RegimeClassification
|
||||||
|
from services.signal_engine.config import ProbabilisticConfig
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
ConfluenceSignal,
|
||||||
|
NormalizedInput,
|
||||||
|
SignalDirection,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
from services.signal_engine.probabilistic import (
|
||||||
|
_compute_ev_r,
|
||||||
|
_compute_likelihood_ratios,
|
||||||
|
_logit,
|
||||||
|
_regime_to_prior,
|
||||||
|
_shannon_entropy,
|
||||||
|
_sigmoid,
|
||||||
|
run_probabilistic_pipeline,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_normalized(
|
||||||
|
macro_bias: float = 0.5,
|
||||||
|
valuation_score: float | None = 0.7,
|
||||||
|
earnings_proximity_days: int | None = 30,
|
||||||
|
) -> NormalizedInput:
|
||||||
|
return NormalizedInput(
|
||||||
|
ticker="TEST",
|
||||||
|
evaluated_at=datetime.now(tz=timezone.utc),
|
||||||
|
bars={},
|
||||||
|
macro_bias=macro_bias,
|
||||||
|
valuation_score=valuation_score,
|
||||||
|
earnings_proximity_days=earnings_proximity_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_regime(
|
||||||
|
regime: MarketRegime = MarketRegime.TREND_FOLLOWING,
|
||||||
|
trend_indicator: float = 1.0,
|
||||||
|
) -> RegimeClassification:
|
||||||
|
return RegimeClassification(
|
||||||
|
regime=regime,
|
||||||
|
trend_indicator=trend_indicator,
|
||||||
|
volatility_ratio=1.0,
|
||||||
|
bullish_threshold=0.15,
|
||||||
|
bearish_threshold=-0.15,
|
||||||
|
contradiction_penalty_multiplier=0.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_confluence(
|
||||||
|
signal_type: str = "fibonacci",
|
||||||
|
direction: SignalDirection = SignalDirection.BULLISH,
|
||||||
|
confluence_score: float = 0.8,
|
||||||
|
active_timeframes: list[str] | None = None,
|
||||||
|
) -> ConfluenceSignal:
|
||||||
|
if active_timeframes is None:
|
||||||
|
active_timeframes = ["D", "W"]
|
||||||
|
return ConfluenceSignal(
|
||||||
|
signal_type=signal_type,
|
||||||
|
direction=direction,
|
||||||
|
confluence_score=confluence_score,
|
||||||
|
active_timeframes=active_timeframes,
|
||||||
|
per_timeframe={tf: confluence_score for tf in active_timeframes},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = ProbabilisticConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Regime → prior mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegimeToPrior:
|
||||||
|
def test_trend_following_bullish(self):
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
|
||||||
|
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.58
|
||||||
|
|
||||||
|
def test_trend_following_bearish(self):
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=-1.0)
|
||||||
|
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
|
||||||
|
|
||||||
|
def test_trend_following_zero_indicator(self):
|
||||||
|
"""Zero trend_indicator is not positive → bear prior."""
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=0.0)
|
||||||
|
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
|
||||||
|
|
||||||
|
def test_mean_reversion(self):
|
||||||
|
regime = _make_regime(MarketRegime.MEAN_REVERSION)
|
||||||
|
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.50
|
||||||
|
|
||||||
|
def test_panic(self):
|
||||||
|
regime = _make_regime(MarketRegime.PANIC)
|
||||||
|
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
|
||||||
|
|
||||||
|
def test_uncertainty(self):
|
||||||
|
regime = _make_regime(MarketRegime.UNCERTAINTY)
|
||||||
|
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.50
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logit / sigmoid helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogitSigmoid:
|
||||||
|
def test_logit_sigmoid_round_trip(self):
|
||||||
|
for p in [0.1, 0.25, 0.5, 0.75, 0.9]:
|
||||||
|
assert abs(_sigmoid(_logit(p)) - p) < 1e-10
|
||||||
|
|
||||||
|
def test_logit_at_half(self):
|
||||||
|
assert abs(_logit(0.5)) < 1e-10
|
||||||
|
|
||||||
|
def test_sigmoid_at_zero(self):
|
||||||
|
assert abs(_sigmoid(0.0) - 0.5) < 1e-10
|
||||||
|
|
||||||
|
def test_sigmoid_large_positive(self):
|
||||||
|
assert _sigmoid(1000) == 1.0
|
||||||
|
|
||||||
|
def test_sigmoid_large_negative(self):
|
||||||
|
assert _sigmoid(-1000) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shannon entropy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestShannonEntropy:
|
||||||
|
def test_max_at_half(self):
|
||||||
|
assert abs(_shannon_entropy(0.5) - 1.0) < 1e-10
|
||||||
|
|
||||||
|
def test_zero_at_boundaries(self):
|
||||||
|
assert _shannon_entropy(0.0) == 0.0
|
||||||
|
assert _shannon_entropy(1.0) == 0.0
|
||||||
|
|
||||||
|
def test_symmetric(self):
|
||||||
|
assert abs(_shannon_entropy(0.3) - _shannon_entropy(0.7)) < 1e-10
|
||||||
|
|
||||||
|
def test_in_range(self):
|
||||||
|
for p in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]:
|
||||||
|
h = _shannon_entropy(p)
|
||||||
|
assert 0.0 <= h <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Likelihood ratio computation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLikelihoodRatios:
|
||||||
|
def test_bullish_signal_produces_lr_gt_1(self):
|
||||||
|
sig = _make_confluence(direction=SignalDirection.BULLISH, confluence_score=0.8)
|
||||||
|
lrs = _compute_likelihood_ratios([sig])
|
||||||
|
assert len(lrs) == 1
|
||||||
|
assert lrs[0].lr > 1.0
|
||||||
|
assert lrs[0].log_lr > 0.0
|
||||||
|
|
||||||
|
def test_bearish_signal_produces_lr_lt_1(self):
|
||||||
|
sig = _make_confluence(direction=SignalDirection.BEARISH, confluence_score=0.8)
|
||||||
|
lrs = _compute_likelihood_ratios([sig])
|
||||||
|
assert len(lrs) == 1
|
||||||
|
assert lrs[0].lr < 1.0
|
||||||
|
assert lrs[0].log_lr < 0.0
|
||||||
|
|
||||||
|
def test_neutral_signal_produces_lr_gt_1(self):
|
||||||
|
"""Neutral signals still contribute based on strength."""
|
||||||
|
sig = _make_confluence(direction=SignalDirection.NEUTRAL, confluence_score=0.8)
|
||||||
|
lrs = _compute_likelihood_ratios([sig])
|
||||||
|
assert len(lrs) == 1
|
||||||
|
# Neutral is treated as bullish evidence (no inversion)
|
||||||
|
assert lrs[0].lr > 1.0
|
||||||
|
|
||||||
|
def test_empty_signals(self):
|
||||||
|
lrs = _compute_likelihood_ratios([])
|
||||||
|
assert lrs == []
|
||||||
|
|
||||||
|
def test_cluster_assignment(self):
|
||||||
|
sig = _make_confluence(signal_type="ma_stack")
|
||||||
|
lrs = _compute_likelihood_ratios([sig])
|
||||||
|
assert lrs[0].cluster == "momentum"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EV_R computation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvR:
|
||||||
|
def test_ev_r_high_p_up(self):
|
||||||
|
signals = [_make_confluence(confluence_score=0.8)]
|
||||||
|
ev_r = _compute_ev_r(0.8, signals)
|
||||||
|
# E[win_R] = 0.8 * 2.0 = 1.6
|
||||||
|
# EV_R = 0.8 * 1.6 - 0.2 * 1.0 = 1.28 - 0.2 = 1.08
|
||||||
|
assert abs(ev_r - 1.08) < 1e-10
|
||||||
|
|
||||||
|
def test_ev_r_at_half(self):
|
||||||
|
signals = [_make_confluence(confluence_score=0.5)]
|
||||||
|
ev_r = _compute_ev_r(0.5, signals)
|
||||||
|
# E[win_R] = 0.5 * 2.0 = 1.0
|
||||||
|
# EV_R = 0.5 * 1.0 - 0.5 * 1.0 = 0.0
|
||||||
|
assert abs(ev_r) < 1e-10
|
||||||
|
|
||||||
|
def test_ev_r_no_signals(self):
|
||||||
|
ev_r = _compute_ev_r(0.7, [])
|
||||||
|
# E[win_R] = 1.0 (fallback)
|
||||||
|
# EV_R = 0.7 * 1.0 - 0.3 * 1.0 = 0.4
|
||||||
|
assert abs(ev_r - 0.4) < 1e-10
|
||||||
|
|
||||||
|
def test_ev_r_monotonic_with_p_up(self):
|
||||||
|
signals = [_make_confluence(confluence_score=0.8)]
|
||||||
|
ev_low = _compute_ev_r(0.5, signals)
|
||||||
|
ev_high = _compute_ev_r(0.8, signals)
|
||||||
|
assert ev_high > ev_low
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Full pipeline — verdict tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbabilisticPipeline:
|
||||||
|
def test_no_signals_returns_prior_based_result(self):
|
||||||
|
"""With no signals, P_up equals the prior."""
|
||||||
|
normalized = _make_normalized()
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
|
||||||
|
result = run_probabilistic_pipeline(normalized, [], regime, DEFAULT_CONFIG)
|
||||||
|
assert abs(result.p_up - 0.58) < 1e-6
|
||||||
|
assert result.prior == 0.58
|
||||||
|
|
||||||
|
def test_buy_verdict_with_strong_signals(self):
|
||||||
|
"""Strong bullish signals + favorable conditions → BUY."""
|
||||||
|
normalized = _make_normalized(macro_bias=0.5, valuation_score=0.7)
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
|
||||||
|
# Multiple strong bullish signals from different clusters
|
||||||
|
signals = [
|
||||||
|
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
|
||||||
|
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.85),
|
||||||
|
_make_confluence("rsi", SignalDirection.BULLISH, 0.8),
|
||||||
|
_make_confluence("valuation", SignalDirection.BULLISH, 0.75),
|
||||||
|
]
|
||||||
|
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
|
||||||
|
# With strong signals and bull prior, P_up should be high
|
||||||
|
assert result.p_up >= 0.60
|
||||||
|
# Verdict depends on all conditions being met
|
||||||
|
assert result.verdict in (Verdict.BUY, Verdict.WATCH)
|
||||||
|
|
||||||
|
def test_skip_on_high_entropy(self):
|
||||||
|
"""P_up near 0.5 → high entropy → SKIP."""
|
||||||
|
normalized = _make_normalized()
|
||||||
|
regime = _make_regime(MarketRegime.MEAN_REVERSION) # prior = 0.50
|
||||||
|
# No signals → P_up stays at 0.50 → entropy = 1.0 > 0.95
|
||||||
|
result = run_probabilistic_pipeline(normalized, [], regime, DEFAULT_CONFIG)
|
||||||
|
assert result.verdict == Verdict.SKIP
|
||||||
|
assert result.entropy > 0.95
|
||||||
|
assert any("high_entropy" in r for r in result.reasoning)
|
||||||
|
|
||||||
|
def test_skip_on_low_p_up(self):
|
||||||
|
"""Bearish signals → low P_up → SKIP."""
|
||||||
|
normalized = _make_normalized()
|
||||||
|
regime = _make_regime(MarketRegime.PANIC) # prior = 0.42
|
||||||
|
signals = [
|
||||||
|
_make_confluence("fibonacci", SignalDirection.BEARISH, 0.9),
|
||||||
|
_make_confluence("ma_stack", SignalDirection.BEARISH, 0.85),
|
||||||
|
]
|
||||||
|
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
|
||||||
|
assert result.p_up < 0.55
|
||||||
|
assert result.verdict == Verdict.SKIP
|
||||||
|
|
||||||
|
def test_watch_verdict(self):
|
||||||
|
"""Moderate signals → WATCH (P_up >= 0.55 but not all BUY conditions)."""
|
||||||
|
normalized = _make_normalized(macro_bias=-0.1) # macro_bias <= 0 blocks BUY
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
|
||||||
|
signals = [
|
||||||
|
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.8),
|
||||||
|
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.75),
|
||||||
|
]
|
||||||
|
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
|
||||||
|
# P_up should be above 0.55 with bull prior + bullish signals
|
||||||
|
if result.p_up >= 0.55 and result.entropy <= 0.95:
|
||||||
|
assert result.verdict == Verdict.WATCH
|
||||||
|
|
||||||
|
def test_macro_bias_blocks_buy(self):
|
||||||
|
"""macro_bias <= 0 prevents BUY even with high P_up."""
|
||||||
|
normalized = _make_normalized(macro_bias=0.0, valuation_score=0.8)
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
|
||||||
|
signals = [
|
||||||
|
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
|
||||||
|
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
|
||||||
|
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
|
||||||
|
_make_confluence("valuation", SignalDirection.BULLISH, 0.8),
|
||||||
|
]
|
||||||
|
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
|
||||||
|
assert result.verdict != Verdict.BUY
|
||||||
|
|
||||||
|
def test_valuation_blocks_buy(self):
|
||||||
|
"""valuation_score < 0.5 prevents BUY."""
|
||||||
|
normalized = _make_normalized(macro_bias=0.5, valuation_score=0.3)
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
|
||||||
|
signals = [
|
||||||
|
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
|
||||||
|
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
|
||||||
|
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
|
||||||
|
_make_confluence("valuation", SignalDirection.BULLISH, 0.8),
|
||||||
|
]
|
||||||
|
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
|
||||||
|
assert result.verdict != Verdict.BUY
|
||||||
|
|
||||||
|
def test_result_fields_populated(self):
|
||||||
|
"""All ProbabilisticResult fields are populated correctly."""
|
||||||
|
normalized = _make_normalized()
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
|
||||||
|
signals = [_make_confluence("fibonacci", SignalDirection.BULLISH, 0.7)]
|
||||||
|
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
assert 0.0 <= result.p_up <= 1.0
|
||||||
|
assert 0.0 <= result.entropy <= 1.0
|
||||||
|
assert isinstance(result.ev_r, float)
|
||||||
|
assert result.prior == 0.58
|
||||||
|
assert result.posterior == result.p_up
|
||||||
|
assert result.regime == "trend_following"
|
||||||
|
assert len(result.likelihood_ratios) == 1
|
||||||
|
assert len(result.reasoning) > 0
|
||||||
|
|
||||||
|
def test_none_valuation_treated_as_zero(self):
|
||||||
|
"""None valuation_score is treated as 0.0 for verdict logic."""
|
||||||
|
normalized = _make_normalized(valuation_score=None)
|
||||||
|
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
|
||||||
|
signals = [
|
||||||
|
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
|
||||||
|
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
|
||||||
|
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
|
||||||
|
]
|
||||||
|
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
|
||||||
|
# valuation_score=None → 0.0 < 0.5 → BUY blocked
|
||||||
|
assert result.verdict != Verdict.BUY
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""Unit tests for services.signal_engine.signals.rsi — RSI evaluator.
|
||||||
|
|
||||||
|
Requirements: 2.3, 2.6, 2.7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from services.signal_engine.models import OHLCVBar, SignalDirection
|
||||||
|
from services.signal_engine.signals.rsi import (
|
||||||
|
DEFAULT_MIN_BARS,
|
||||||
|
DEFAULT_RSI_PERIOD,
|
||||||
|
OVERBOUGHT_THRESHOLD,
|
||||||
|
OVERSOLD_THRESHOLD,
|
||||||
|
RSIEvaluator,
|
||||||
|
compute_rsi,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(close: float) -> OHLCVBar:
|
||||||
|
"""Create a minimal OHLCVBar for testing."""
|
||||||
|
return OHLCVBar(
|
||||||
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
open=close,
|
||||||
|
high=close,
|
||||||
|
low=close,
|
||||||
|
close=close,
|
||||||
|
volume=1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bars(n: int, close: float = 100.0) -> list[OHLCVBar]:
|
||||||
|
"""Create *n* identical bars with the same close price."""
|
||||||
|
return [_bar(close) for _ in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def _trending_bars(n: int, start: float, step: float) -> list[OHLCVBar]:
|
||||||
|
"""Create *n* bars with linearly increasing/decreasing close prices."""
|
||||||
|
return [_bar(start + i * step) for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def _alternating_bars(
|
||||||
|
n: int,
|
||||||
|
base: float,
|
||||||
|
gain: float,
|
||||||
|
loss: float,
|
||||||
|
) -> list[OHLCVBar]:
|
||||||
|
"""Create bars that alternate between gaining and losing.
|
||||||
|
|
||||||
|
Useful for producing RSI values in the neutral zone.
|
||||||
|
"""
|
||||||
|
bars: list[OHLCVBar] = [_bar(base)]
|
||||||
|
price = base
|
||||||
|
for i in range(1, n):
|
||||||
|
if i % 2 == 1:
|
||||||
|
price += gain
|
||||||
|
else:
|
||||||
|
price -= loss
|
||||||
|
bars.append(_bar(price))
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_period() -> None:
|
||||||
|
assert DEFAULT_RSI_PERIOD == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_min_bars() -> None:
|
||||||
|
assert DEFAULT_MIN_BARS == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_thresholds() -> None:
|
||||||
|
assert OVERBOUGHT_THRESHOLD == 70.0
|
||||||
|
assert OVERSOLD_THRESHOLD == 30.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Insufficient data → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_insufficient_bars() -> None:
|
||||||
|
"""Requirement 2.6: return None when fewer than 15 bars."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
bars = _make_bars(14)
|
||||||
|
assert evaluator.evaluate(bars, "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_empty_bars() -> None:
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
assert evaluator.evaluate([], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_with_one_bar() -> None:
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
assert evaluator.evaluate([_bar(100.0)], "D") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Neutral zone → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_in_neutral_zone() -> None:
|
||||||
|
"""RSI between 30 and 70 should return None (no signal)."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
# Alternating gains and losses produce RSI near 50
|
||||||
|
bars = _alternating_bars(30, base=100.0, gain=1.0, loss=1.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
# RSI should be near 50 → neutral → None
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_flat_market_returns_none() -> None:
|
||||||
|
"""All bars with the same close → no price changes → no signal.
|
||||||
|
|
||||||
|
When all changes are zero, avg_gain=0 and avg_loss=0.
|
||||||
|
avg_loss=0 means RSI=100, which is overbought. But with truly flat
|
||||||
|
prices (no gains, no losses), RSI is technically 100 (all gains are 0,
|
||||||
|
all losses are 0 → RS = 0/0 edge case handled as RSI=100).
|
||||||
|
"""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
bars = _make_bars(30, close=100.0)
|
||||||
|
rsi = compute_rsi(bars)
|
||||||
|
# With zero changes: avg_gain=0, avg_loss=0 → RSI=100 (per our implementation)
|
||||||
|
assert rsi == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Overbought signal (RSI > 70) → BEARISH
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_overbought_produces_bearish_signal() -> None:
|
||||||
|
"""Requirement 2.3: RSI > 70 → BEARISH signal (overbought → potential reversal down)."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
# Strong uptrend: all gains, no losses → RSI approaches 100
|
||||||
|
bars = _trending_bars(30, start=50.0, step=2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "rsi"
|
||||||
|
assert result.direction == SignalDirection.BEARISH
|
||||||
|
assert result.metadata["zone"] == "overbought"
|
||||||
|
assert result.metadata["rsi"] > OVERBOUGHT_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Oversold signal (RSI < 30) → BULLISH
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversold_produces_bullish_signal() -> None:
|
||||||
|
"""Requirement 2.3: RSI < 30 → BULLISH signal (oversold → potential reversal up)."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
# Strong downtrend: all losses, no gains → RSI approaches 0
|
||||||
|
bars = _trending_bars(30, start=200.0, step=-2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "rsi"
|
||||||
|
assert result.direction == SignalDirection.BULLISH
|
||||||
|
assert result.metadata["zone"] == "oversold"
|
||||||
|
assert result.metadata["rsi"] < OVERSOLD_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Strength scaling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_overbought_strength_scales_with_distance() -> None:
|
||||||
|
"""Strength = (RSI - 70) / 30, clamped to [0, 1]."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
# Strong uptrend → RSI near 100 → high strength
|
||||||
|
bars = _trending_bars(30, start=50.0, step=3.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
rsi = result.metadata["rsi"]
|
||||||
|
expected_strength = min(1.0, max(0.0, (rsi - 70.0) / 30.0))
|
||||||
|
assert abs(result.strength - expected_strength) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversold_strength_scales_with_distance() -> None:
|
||||||
|
"""Strength = (30 - RSI) / 30, clamped to [0, 1]."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
# Strong downtrend → RSI near 0 → high strength
|
||||||
|
bars = _trending_bars(30, start=200.0, step=-3.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
rsi = result.metadata["rsi"]
|
||||||
|
expected_strength = min(1.0, max(0.0, (30.0 - rsi) / 30.0))
|
||||||
|
assert abs(result.strength - expected_strength) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_strength_clamped_to_unit_interval() -> None:
|
||||||
|
"""Strength must always be in [0, 1]."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
# Extreme uptrend → RSI ≈ 100 → strength should be clamped to 1.0
|
||||||
|
bars = _trending_bars(30, start=10.0, step=5.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert 0.0 <= result.strength <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Confidence = strength * 0.85
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_equals_strength_times_085() -> None:
|
||||||
|
"""Confidence = strength * 0.85."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
bars = _trending_bars(30, start=50.0, step=2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
expected_confidence = result.strength * 0.85
|
||||||
|
assert abs(result.confidence - expected_confidence) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal result structure (Requirement 2.7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_result_structure() -> None:
|
||||||
|
"""Requirement 2.7: SignalResult has all required fields."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
bars = _trending_bars(30, start=50.0, step=2.0)
|
||||||
|
result = evaluator.evaluate(bars, "H4")
|
||||||
|
assert result is not None
|
||||||
|
assert result.signal_type == "rsi"
|
||||||
|
assert result.timeframe == "H4"
|
||||||
|
assert 0.0 <= result.strength <= 1.0
|
||||||
|
assert 0.0 <= result.confidence <= 1.0
|
||||||
|
assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_contains_rsi_and_period() -> None:
|
||||||
|
"""Metadata should include RSI value and period used."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
bars = _trending_bars(30, start=50.0, step=2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
meta = result.metadata
|
||||||
|
assert "rsi" in meta
|
||||||
|
assert "period" in meta
|
||||||
|
assert meta["period"] == 14
|
||||||
|
assert isinstance(meta["rsi"], float)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timeframe passthrough
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeframe_passthrough() -> None:
|
||||||
|
"""The timeframe label is passed through to the result."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
bars = _trending_bars(30, start=50.0, step=2.0)
|
||||||
|
for tf in ("M30", "H1", "H4", "D", "W", "M"):
|
||||||
|
result = evaluator.evaluate(bars, tf)
|
||||||
|
assert result is not None
|
||||||
|
assert result.timeframe == tf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Boundary: exactly 15 bars
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_15_bars_works() -> None:
|
||||||
|
"""Exactly 15 bars (period + 1) should be sufficient."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
bars = _trending_bars(15, start=50.0, step=2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
# Should produce a result (strong uptrend → overbought)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_14_bars_returns_none() -> None:
|
||||||
|
"""14 bars is insufficient for a 14-period RSI."""
|
||||||
|
evaluator = RSIEvaluator()
|
||||||
|
bars = _trending_bars(14, start=50.0, step=2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# compute_rsi standalone function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_rsi_all_gains() -> None:
|
||||||
|
"""All gains, no losses → RSI approaches 100."""
|
||||||
|
bars = _trending_bars(30, start=50.0, step=1.0)
|
||||||
|
rsi = compute_rsi(bars)
|
||||||
|
assert rsi is not None
|
||||||
|
assert rsi > 95.0 # Should be very close to 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_rsi_all_losses() -> None:
|
||||||
|
"""All losses, no gains → RSI approaches 0."""
|
||||||
|
bars = _trending_bars(30, start=200.0, step=-1.0)
|
||||||
|
rsi = compute_rsi(bars)
|
||||||
|
assert rsi is not None
|
||||||
|
assert rsi < 5.0 # Should be very close to 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_rsi_insufficient_data() -> None:
|
||||||
|
"""Returns None when fewer than period + 1 bars."""
|
||||||
|
bars = _make_bars(10)
|
||||||
|
rsi = compute_rsi(bars)
|
||||||
|
assert rsi is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_rsi_range() -> None:
|
||||||
|
"""RSI should always be in [0, 100]."""
|
||||||
|
# Mixed trend
|
||||||
|
bars = _trending_bars(15, start=100.0, step=0.5) + _trending_bars(15, start=107.0, step=-0.3)
|
||||||
|
rsi = compute_rsi(bars)
|
||||||
|
assert rsi is not None
|
||||||
|
assert 0.0 <= rsi <= 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom period
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_period() -> None:
|
||||||
|
"""RSIEvaluator with a custom period should use that period."""
|
||||||
|
evaluator = RSIEvaluator(period=7)
|
||||||
|
assert evaluator.period == 7
|
||||||
|
assert evaluator.min_bars == 8
|
||||||
|
# 8 bars with uptrend should work
|
||||||
|
bars = _trending_bars(8, start=50.0, step=2.0)
|
||||||
|
result = evaluator.evaluate(bars, "D")
|
||||||
|
assert result is not None
|
||||||
|
assert result.metadata["period"] == 7
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
"""Integration tests for services.signal_engine.worker — Top-level orchestrator.
|
||||||
|
|
||||||
|
Tests the full evaluation tick flow with mocked DB/Redis, pipeline failure
|
||||||
|
isolation, hard filter short-circuit, and shadow mode behavior.
|
||||||
|
|
||||||
|
Requirements: 11.1, 11.2, 11.3, 11.6, 13.1, 13.6, 13.7, 15.1, 15.4, 16.1, 16.6
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.signal_engine.config import SignalEngineConfig
|
||||||
|
from services.signal_engine.models import (
|
||||||
|
DeltaResult,
|
||||||
|
HeuristicResult,
|
||||||
|
NormalizedInput,
|
||||||
|
ProbabilisticResult,
|
||||||
|
Verdict,
|
||||||
|
)
|
||||||
|
from services.signal_engine.worker import evaluate_tick
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _default_config(**overrides: object) -> SignalEngineConfig:
|
||||||
|
"""Build a SignalEngineConfig with sensible test defaults."""
|
||||||
|
defaults = {
|
||||||
|
"dual_pipeline_enabled": True,
|
||||||
|
"shadow_mode": False,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return SignalEngineConfig(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_input(
|
||||||
|
*,
|
||||||
|
ticker: str = "AAPL",
|
||||||
|
macro_bias: float = 0.5,
|
||||||
|
valuation_score: float = 0.8,
|
||||||
|
earnings_proximity_days: int = 30,
|
||||||
|
current_price: float = 150.0,
|
||||||
|
) -> NormalizedInput:
|
||||||
|
"""Build a NormalizedInput with test defaults that pass hard filters."""
|
||||||
|
return NormalizedInput(
|
||||||
|
ticker=ticker,
|
||||||
|
evaluated_at=datetime.now(tz=timezone.utc),
|
||||||
|
bars={},
|
||||||
|
valuation_score=valuation_score,
|
||||||
|
earnings_proximity_days=earnings_proximity_days,
|
||||||
|
macro_bias=macro_bias,
|
||||||
|
open_positions=[],
|
||||||
|
closing_prices=[100.0 + i for i in range(120)],
|
||||||
|
returns=[0.01] * 119,
|
||||||
|
current_price=current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _heuristic_buy() -> HeuristicResult:
|
||||||
|
return HeuristicResult(
|
||||||
|
verdict=Verdict.BUY,
|
||||||
|
confidence=0.85,
|
||||||
|
s_total=1.5,
|
||||||
|
s_company=1.0,
|
||||||
|
s_macro=0.3,
|
||||||
|
s_competitive=0.2,
|
||||||
|
signal_weights=[],
|
||||||
|
reasoning=["BUY: all conditions met"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _heuristic_skip() -> HeuristicResult:
|
||||||
|
return HeuristicResult(
|
||||||
|
verdict=Verdict.SKIP,
|
||||||
|
confidence=0.3,
|
||||||
|
s_total=0.5,
|
||||||
|
s_company=0.3,
|
||||||
|
s_macro=0.1,
|
||||||
|
s_competitive=0.1,
|
||||||
|
signal_weights=[],
|
||||||
|
reasoning=["SKIP: low confidence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probabilistic_buy() -> ProbabilisticResult:
|
||||||
|
return ProbabilisticResult(
|
||||||
|
verdict=Verdict.BUY,
|
||||||
|
p_up=0.72,
|
||||||
|
entropy=0.6,
|
||||||
|
ev_r=2.0,
|
||||||
|
prior=0.58,
|
||||||
|
posterior=0.72,
|
||||||
|
likelihood_ratios=[],
|
||||||
|
regime="trend_following",
|
||||||
|
reasoning=["BUY: all conditions met"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probabilistic_skip() -> ProbabilisticResult:
|
||||||
|
return ProbabilisticResult(
|
||||||
|
verdict=Verdict.SKIP,
|
||||||
|
p_up=0.4,
|
||||||
|
entropy=0.95,
|
||||||
|
ev_r=0.5,
|
||||||
|
prior=0.5,
|
||||||
|
posterior=0.4,
|
||||||
|
likelihood_ratios=[],
|
||||||
|
regime="uncertainty",
|
||||||
|
reasoning=["SKIP: low P_up"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_result(agreement: bool = True) -> DeltaResult:
|
||||||
|
return DeltaResult(
|
||||||
|
agreement=agreement,
|
||||||
|
confidence_delta=0.1,
|
||||||
|
heuristic_verdict="BUY",
|
||||||
|
probabilistic_verdict="BUY",
|
||||||
|
disagreement_reasons=[],
|
||||||
|
rolling_agreement_rate=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. Full tick evaluation with mocked data (Req 11.1, 11.2, 11.5, 11.6)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullTickEvaluation:
|
||||||
|
"""Test the full evaluation tick with both pipelines producing BUY."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_tick_both_buy_publishes_to_queue(self) -> None:
|
||||||
|
"""Both pipelines BUY → output persisted and published to trading queue."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
redis_client = AsyncMock()
|
||||||
|
config = _default_config()
|
||||||
|
|
||||||
|
normalized = _normalized_input()
|
||||||
|
heuristic = _heuristic_buy()
|
||||||
|
probabilistic = _probabilistic_buy()
|
||||||
|
delta = _delta_result(agreement=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.normalize_input",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=normalized,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_exits",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_hard_filters",
|
||||||
|
) as mock_hf,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker._evaluate_signals",
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.compute_confluence",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.classify_regime",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_heuristic_pipeline",
|
||||||
|
return_value=heuristic,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_probabilistic_pipeline",
|
||||||
|
return_value=probabilistic,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.analyze_delta",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=delta,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.persist_signal_output",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_persist,
|
||||||
|
):
|
||||||
|
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
|
||||||
|
|
||||||
|
output = await evaluate_tick(pool, redis_client, "AAPL", config)
|
||||||
|
|
||||||
|
assert output is not None
|
||||||
|
assert output.ticker == "AAPL"
|
||||||
|
assert output.heuristic_verdict == "BUY"
|
||||||
|
assert output.probabilistic_verdict == "BUY"
|
||||||
|
|
||||||
|
# Persistence was called
|
||||||
|
mock_persist.assert_awaited_once()
|
||||||
|
|
||||||
|
# Trading queue was published to (at least one BUY, not shadow mode)
|
||||||
|
redis_client.rpush.assert_awaited_once()
|
||||||
|
call_args = redis_client.rpush.call_args
|
||||||
|
assert call_args[0][0] == "stonks:queue:trading_decisions"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_tick_no_buy_does_not_publish(self) -> None:
|
||||||
|
"""Both pipelines SKIP → output persisted but NOT published to queue."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
redis_client = AsyncMock()
|
||||||
|
config = _default_config()
|
||||||
|
|
||||||
|
normalized = _normalized_input()
|
||||||
|
heuristic = _heuristic_skip()
|
||||||
|
probabilistic = _probabilistic_skip()
|
||||||
|
delta = _delta_result(agreement=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.normalize_input",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=normalized,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_exits",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_hard_filters",
|
||||||
|
) as mock_hf,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker._evaluate_signals",
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.compute_confluence",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.classify_regime",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_heuristic_pipeline",
|
||||||
|
return_value=heuristic,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_probabilistic_pipeline",
|
||||||
|
return_value=probabilistic,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.analyze_delta",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=delta,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.persist_signal_output",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_persist,
|
||||||
|
):
|
||||||
|
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
|
||||||
|
|
||||||
|
output = await evaluate_tick(pool, redis_client, "AAPL", config)
|
||||||
|
|
||||||
|
assert output is not None
|
||||||
|
# Persisted
|
||||||
|
mock_persist.assert_awaited_once()
|
||||||
|
# NOT published to trading queue
|
||||||
|
redis_client.rpush.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. Pipeline failure isolation (Req 11.3)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipelineFailureIsolation:
|
||||||
|
"""One pipeline fails → SKIP verdict for that pipeline, other completes."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_heuristic_fails_probabilistic_completes(self) -> None:
|
||||||
|
"""Heuristic raises exception → SKIP, probabilistic completes normally."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
redis_client = AsyncMock()
|
||||||
|
config = _default_config()
|
||||||
|
|
||||||
|
normalized = _normalized_input()
|
||||||
|
probabilistic = _probabilistic_buy()
|
||||||
|
delta = _delta_result(agreement=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.normalize_input",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=normalized,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_exits",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_hard_filters",
|
||||||
|
) as mock_hf,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker._evaluate_signals",
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.compute_confluence",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.classify_regime",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_heuristic_pipeline",
|
||||||
|
side_effect=RuntimeError("heuristic boom"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_probabilistic_pipeline",
|
||||||
|
return_value=probabilistic,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.analyze_delta",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=delta,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.persist_signal_output",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_persist,
|
||||||
|
):
|
||||||
|
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
|
||||||
|
|
||||||
|
output = await evaluate_tick(pool, redis_client, "AAPL", config)
|
||||||
|
|
||||||
|
assert output is not None
|
||||||
|
# Heuristic fell back to SKIP
|
||||||
|
assert output.heuristic_verdict == "SKIP"
|
||||||
|
# Probabilistic completed normally
|
||||||
|
assert output.probabilistic_verdict == "BUY"
|
||||||
|
# Still persisted
|
||||||
|
mock_persist.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probabilistic_fails_heuristic_completes(self) -> None:
|
||||||
|
"""Probabilistic raises exception → SKIP, heuristic completes normally."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
redis_client = AsyncMock()
|
||||||
|
config = _default_config()
|
||||||
|
|
||||||
|
normalized = _normalized_input()
|
||||||
|
heuristic = _heuristic_buy()
|
||||||
|
delta = _delta_result(agreement=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.normalize_input",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=normalized,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_exits",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_hard_filters",
|
||||||
|
) as mock_hf,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker._evaluate_signals",
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.compute_confluence",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.classify_regime",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_heuristic_pipeline",
|
||||||
|
return_value=heuristic,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_probabilistic_pipeline",
|
||||||
|
side_effect=RuntimeError("probabilistic boom"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.analyze_delta",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=delta,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.persist_signal_output",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_persist,
|
||||||
|
):
|
||||||
|
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
|
||||||
|
|
||||||
|
output = await evaluate_tick(pool, redis_client, "AAPL", config)
|
||||||
|
|
||||||
|
assert output is not None
|
||||||
|
assert output.heuristic_verdict == "BUY"
|
||||||
|
assert output.probabilistic_verdict == "SKIP"
|
||||||
|
mock_persist.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_both_pipelines_fail_returns_none(self) -> None:
|
||||||
|
"""Both pipelines raise exceptions → returns None."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
redis_client = AsyncMock()
|
||||||
|
config = _default_config()
|
||||||
|
|
||||||
|
normalized = _normalized_input()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.normalize_input",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=normalized,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_exits",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_hard_filters",
|
||||||
|
) as mock_hf,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker._evaluate_signals",
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.compute_confluence",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.classify_regime",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_heuristic_pipeline",
|
||||||
|
side_effect=RuntimeError("heuristic boom"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_probabilistic_pipeline",
|
||||||
|
side_effect=RuntimeError("probabilistic boom"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.persist_signal_output",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_persist,
|
||||||
|
):
|
||||||
|
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
|
||||||
|
|
||||||
|
output = await evaluate_tick(pool, redis_client, "AAPL", config)
|
||||||
|
|
||||||
|
assert output is None
|
||||||
|
# Nothing persisted when both fail
|
||||||
|
mock_persist.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. Hard filter short-circuit (Req 4.1, 4.2, 4.3)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHardFilterShortCircuit:
|
||||||
|
"""Hard filter triggers → returns None without running pipelines."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hard_filter_returns_none(self) -> None:
|
||||||
|
"""When hard filter triggers, evaluate_tick returns None."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
redis_client = AsyncMock()
|
||||||
|
config = _default_config()
|
||||||
|
|
||||||
|
normalized = _normalized_input(macro_bias=-1.0)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.normalize_input",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=normalized,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_exits",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_hard_filters",
|
||||||
|
) as mock_hf,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_heuristic_pipeline",
|
||||||
|
) as mock_heuristic,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_probabilistic_pipeline",
|
||||||
|
) as mock_probabilistic,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.persist_signal_output",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_persist,
|
||||||
|
):
|
||||||
|
mock_hf.return_value = MagicMock(
|
||||||
|
filtered=True, reasons=["macro_bias_negative"]
|
||||||
|
)
|
||||||
|
|
||||||
|
output = await evaluate_tick(pool, redis_client, "AAPL", config)
|
||||||
|
|
||||||
|
assert output is None
|
||||||
|
# Pipelines were NOT called
|
||||||
|
mock_heuristic.assert_not_called()
|
||||||
|
mock_probabilistic.assert_not_called()
|
||||||
|
# Nothing persisted
|
||||||
|
mock_persist.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4. Shadow mode behavior (Req 16.6)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestShadowMode:
|
||||||
|
"""Shadow mode: persists output but does NOT publish to trading queue."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shadow_mode_persists_but_does_not_publish(self) -> None:
|
||||||
|
"""In shadow mode, BUY signals are persisted but not forwarded."""
|
||||||
|
pool = AsyncMock()
|
||||||
|
redis_client = AsyncMock()
|
||||||
|
config = _default_config(shadow_mode=True)
|
||||||
|
|
||||||
|
normalized = _normalized_input()
|
||||||
|
heuristic = _heuristic_buy()
|
||||||
|
probabilistic = _probabilistic_buy()
|
||||||
|
delta = _delta_result(agreement=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.normalize_input",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=normalized,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_exits",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.evaluate_hard_filters",
|
||||||
|
) as mock_hf,
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker._evaluate_signals",
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.compute_confluence",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.classify_regime",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_heuristic_pipeline",
|
||||||
|
return_value=heuristic,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.run_probabilistic_pipeline",
|
||||||
|
return_value=probabilistic,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.analyze_delta",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=delta,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"services.signal_engine.worker.persist_signal_output",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_persist,
|
||||||
|
):
|
||||||
|
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
|
||||||
|
|
||||||
|
output = await evaluate_tick(pool, redis_client, "AAPL", config)
|
||||||
|
|
||||||
|
assert output is not None
|
||||||
|
assert output.heuristic_verdict == "BUY"
|
||||||
|
assert output.probabilistic_verdict == "BUY"
|
||||||
|
|
||||||
|
# Persisted (shadow mode still persists)
|
||||||
|
mock_persist.assert_awaited_once()
|
||||||
|
|
||||||
|
# NOT published to trading queue (shadow mode blocks publishing)
|
||||||
|
redis_client.rpush.assert_not_awaited()
|
||||||
Reference in New Issue
Block a user