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

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:
Celes Renata
2026-05-02 07:32:26 +00:00
parent 7e2343ec2c
commit f468e30af0
61 changed files with 14107 additions and 184 deletions
+148
View File
@@ -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
View File
@@ -1,6 +1,6 @@
# 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
@@ -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)
- [Global Event Classifier](#2-global-event-classifier)
- [Thesis Rewriter](#3-thesis-rewriter)
- [LLM Provider Abstraction](#llm-provider-abstraction)
- [Database Schema](#database-schema)
- [ai_agents Table](#ai_agents-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` |
| **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) |
| **Supported Providers** | `ollama`, `vllm` |
| **Prompt Version** | `document-intel-v2` |
| **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:**
- 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
- Document ID for traceability
**Output Schema** (`ExtractionResult`):
**Output Schema** (`ExtractionResult` — defined in `services/extractor/schemas.py`):
```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 the full JSON schema field descriptions
- 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` |
| **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) |
| **Supported Providers** | `ollama`, `vllm` |
| **Prompt Version** | `event-classification-v1` |
| **Schema Version** | `1.0.0` |
| **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)
- Document ID for traceability
**Output Schema** (`GlobalEvent`):
**Output Schema** (`GlobalEvent` — defined in `services/extractor/event_classifier.py`):
```json
{
@@ -141,9 +145,11 @@ as empty arrays.
```
**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
- 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` |
| **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) |
| **Supported Providers** | `ollama`, `vllm` |
| **Prompt Version** | `thesis-rewrite-v1` |
| **Schema Version** | `1.0.0` |
| **Entry Point** | `services/recommendation/main.py``services/recommendation/thesis_llm.py` |
@@ -165,6 +172,7 @@ as empty arrays.
**Output Schema:**
- 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.
- 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:**
@@ -182,11 +190,37 @@ STRICT RULES:
5. Use a neutral, professional tone. Avoid hype or marketing language.
6. Return ONLY the rewritten thesis text. No JSON, no markdown, no
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`):
- Includes the deterministic thesis between delimiters
- 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) |
| `slug` | `VARCHAR(100)` | — | URL-safe identifier (unique), used by `AgentConfigResolver` |
| `purpose` | `TEXT` | `''` | Description of what the agent does |
| `model_provider` | `VARCHAR(50)` | `'ollama'` | LLM provider |
| `model_name` | `VARCHAR(200)` | `'qwen3.5:9b'` | Model identifier |
| `model_provider` | `VARCHAR(50)` | `'ollama'` | LLM provider (`ollama` or `vllm`) |
| `model_name` | `VARCHAR(200)` | `'qwen3.5:9b-fast'` | Model identifier |
| `system_prompt` | `TEXT` | `''` | System prompt sent to the model |
| `user_prompt_template` | `TEXT` | `''` | User prompt template (optional — code-defined templates take precedence) |
| `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.system_prompt, a.system_prompt) AS system_prompt,
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
LEFT JOIN agent_variants v
ON v.agent_id = a.id AND v.is_active = TRUE
@@ -361,7 +402,10 @@ resolver.invalidate() # Clear all entries
### 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.
- **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
@@ -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):
```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 |
| `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
| 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}/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
@@ -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/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
| 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/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
@@ -616,3 +690,20 @@ curl -s -X PUT \
```
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
View File
@@ -142,14 +142,35 @@ Trend projection for a specific trend window.
### 1.5 Market Prices
#### `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 |
|-----------|------|---------|-------------|-------------|
| `limit` | int | `30` | max `200` | Max bars returned |
| `limit` | int | `200` | max `500` | Max bars returned |
- **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
@@ -224,8 +245,6 @@ Get audit events for any entity type and ID.
- **Path params:** `entity_type` (string), `entity_id` (string)
- **Response:** Array of audit event objects
- **Errors:** `404` — No audit events found
### 1.10 Admin: Source Health
@@ -331,6 +350,8 @@ Approve or reject a pending operator approval request.
#### `GET /api/admin/trading/lockouts`
List active symbol lockouts (news-shock, cooldown, manual).
- **Response:** Array of lockout objects
#### `POST /api/admin/trading/lockouts`
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 }`
- **Response:** Updated approval settings
### 1.13 Operational Dashboard
#### `GET /api/ops/ingestion/throughput`
@@ -450,7 +470,7 @@ Trino catalog/schema/table/column metadata for the schema browser.
#### `GET /api/analytics/pg-schema`
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`
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`
List all saved queries.
- **Response:** Array of `{ id, name, description, sql_text, created_by, created_at, updated_at }`
#### `POST /api/analytics/saved-queries` (201)
Save a new query.
- **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 a saved query.
- **Errors:** `404` — Query not found
### 1.16 Macro Signal Layer
#### `GET /api/admin/macro/status`
@@ -501,9 +523,13 @@ List recent global events with filtering.
| `limit` | int | `50` | max `200` | Page size |
| `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}`
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
#### `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 |
| `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
@@ -540,6 +567,7 @@ Historical patterns for a company.
| `catalyst_type` | string | — | Filter by catalyst type |
| `time_horizon` | string | — | Filter by time horizon |
- **Path params:** `ticker` (string)
- **Response:** `{ ticker, patterns[], count }`
#### `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`
Recent competitive signals targeting this company (limit 100).
- **Path params:** `ticker` (string)
- **Response:** `{ ticker, competitive_signals[], count }`
#### `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 |
- **Path params:** `ticker` (string)
- **Response:** `{ ticker, decisions[], count }` — each decision includes `pattern_statistics[]`
### 1.20 AI Agents
#### `GET /api/agents`
@@ -576,9 +605,12 @@ List all AI agent configurations.
|-----------|------|---------|-------------|
| `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 a single agent configuration.
- **Path params:** `agent_id` (UUID string)
- **Errors:** `404` — Agent not found
#### `POST /api/agents` (201)
@@ -603,9 +635,9 @@ Create a new user-defined agent.
| `max_retries` | int | `2` | Max retry attempts |
#### `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
#### `DELETE /api/agents/{agent_id}`
@@ -636,6 +668,8 @@ Hourly performance time-series for an agent.
#### `GET /api/agents/{agent_id}/variants`
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 a single variant.
@@ -680,13 +714,13 @@ Delete a variant. Cannot delete active variants.
#### `POST /api/agents/{agent_id}/clone` (201)
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
#### `POST /api/agents/{agent_id}/variants/{variant_id}/clone` (201)
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
#### `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`
Deactivate the currently active variant. Agent falls back to base configuration.
- **Response:** `{ deactivated: true }`
#### `GET /api/agents/{agent_id}/variants/{variant_id}/performance`
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 |
- **Response:** Same shape as agent performance (invocations, successes, failures, durations, confidence, tokens, success_rate)
#### `GET /api/agents/{agent_id}/variants/{variant_id}/performance/history`
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 |
- **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.500.60, 0.600.70, 0.700.80, 0.800.90, 0.901.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
@@ -756,6 +896,7 @@ List tracked companies.
#### `GET /companies/{company_id}`
Get a single company.
- **Path params:** `company_id` (UUID string)
- **Errors:** `404` — Company not found
#### `PUT /companies/{company_id}`
@@ -783,14 +924,18 @@ List aliases for a company.
Create a new watchlist.
- **Body:** `{ name: string, description?: string }`
- **Response:** `{ id, name, description, active }`
- **Errors:** `409` — Watchlist name already exists
#### `GET /watchlists`
List all watchlists.
- **Response:** Array of `{ id, name, description, active }`
#### `POST /watchlists/{watchlist_id}/members/{company_id}` (201)
Add a company to a watchlist.
- **Response:** `{ status: "added" }`
- **Errors:** `409` — Already a member; `404` — Watchlist or company not found
#### `GET /watchlists/{watchlist_id}/members`
@@ -814,11 +959,14 @@ Add a data source for a company.
| `retention_days` | int | `365` | — | Data retention period |
| `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
#### `GET /companies/{company_id}/sources`
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
#### `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 all exposure profile versions for a company, ordered by version descending.
- **Response:** Array of `ExposureProfileResponse`
### 2.7 Competitor Relationships
#### `POST /companies/{company_id}/competitors` (201)
@@ -863,10 +1013,11 @@ Create a competitor relationship. Records an audit event.
| `bidirectional` | bool | `true` | — | Bidirectional relationship |
| `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
#### `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
@@ -879,6 +1030,7 @@ Update a competitor relationship. Records an audit event with previous state.
#### `DELETE /companies/{company_id}/competitors/{relationship_id}`
Soft-delete a competitor relationship (sets `active=false`). Records an audit event.
- **Response:** `{ status: "deleted", id }`
- **Errors:** `404` — Active relationship not found
### 2.8 Competitor Inference
@@ -923,7 +1075,7 @@ Diagnostic endpoint showing engine internals for troubleshooting.
#### `GET /api/trading/status`
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
#### `PUT /api/trading/config`
@@ -960,7 +1112,13 @@ Resume the trading engine.
#### `POST /api/trading/reset`
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 (01). 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 } }`
- **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 |
| `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
#### `GET /api/trading/metrics`
@@ -992,6 +1152,8 @@ Return historical daily portfolio 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
#### `POST /api/trading/backtest`
@@ -1012,6 +1174,7 @@ Launch a backtest run asynchronously.
#### `GET /api/trading/backtest/{backtest_id}`
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 }`
- Status values: `running`, `completed`, `not_found`, `pending`
@@ -1037,10 +1200,11 @@ Update notification preferences.
All fields optional.
- **Response:** `{ updated: { ...changed fields } }`
- **Errors:** `503` — Engine not initialised
#### `GET /api/trading/notifications/history`
Return recent notifications.
Return recent notifications (placeholder — currently returns empty array).
| Parameter | Type | Default | Constraints | Description |
|-----------|------|---------|-------------|-------------|
@@ -1116,6 +1280,8 @@ List pending approval requests.
#### `GET /approvals/{approval_id}`
Get a single approval request.
- **Path params:** `approval_id` (UUID string)
- **Response:** Approval request object
- **Errors:** `404` — Approval not found; `503` — Database not ready
#### `POST /approvals/{approval_id}/review`
+94 -38
View File
@@ -18,13 +18,13 @@ flowchart TB
end
%% ── Scheduler ─────────────────────────────────────────────────
scheduler["<b>Scheduler</b><br/><i>services.scheduler.app</i><br/>Cadence polling, rate limiting,<br/>backoff &amp; 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
%% ── Ingestion Queue ───────────────────────────────────────────
q_ingestion[["stonks:queue:ingestion"]]
scheduler -->|"rpush job"| q_ingestion
scheduler -->|"rpush job<br/>(company, macro,<br/>global market)"| q_ingestion
%% ── Ingestion Worker ──────────────────────────────────────────
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 ─────────────────────────────────────────────
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["<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
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
```
@@ -70,18 +70,23 @@ flowchart TB
parser -->|"rpush<br/>(standard docs)"| q_extraction
parser -->|"rpush<br/>(macro_event docs)"| q_macro
%% ── Scheduler Recovery ────────────────────────────────────────
scheduler_recovery(("Scheduler<br/><i>stale recovery &amp;<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 ──────────────────────────────────────────
subgraph extractor_svc ["Extractor Service"]
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
q_extraction -->|"lpop"| ext_main
q_macro -->|"lpop"| ext_main
%% ── Ollama LLM ───────────────────────────────────────────────
ollama["<b>Ollama</b><br/><i>LLM Inference</i><br/>document-extractor agent<br/>event-classifier agent"]
ext_main <-->|"HTTP /api/generate"| ollama
ollama["<b>Ollama / vLLM</b><br/><i>LLM Inference</i><br/>document-extractor agent<br/>event-classifier agent"]
ext_main <-->|"HTTP /api/generate<br/>(AgentConfigResolver<br/>selects model + variant)"| ollama
%% ── Signal Layer 1: Company ───────────────────────────────────
subgraph layer1 ["Layer 1 — Company Signals"]
@@ -95,7 +100,7 @@ flowchart TB
subgraph layer2 ["Layer 2 — Macro Signals"]
direction LR
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
end
@@ -106,6 +111,10 @@ flowchart TB
q_agg[["stonks:queue:aggregation"]]
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["<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
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
flowchart TB
%% ── Recommendation Queue ──────────────────────────────────────
@@ -144,19 +155,23 @@ flowchart TB
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
pg_recs[("PostgreSQL<br/><i>recommendations,<br/>recommendation_evidence,<br/>risk_evaluations</i>")]
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 ────────────────────────────────────────────
subgraph trading_loop ["Trading Engine Decision Loop"]
direction TB
poll["Poll recommendations<br/><i>action IN (buy, sell)<br/>mode IN (paper, live)<br/>generated_at &gt; last_poll</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>"]
size["Position sizing<br/><i>Kelly criterion,<br/>risk tier limits</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,<br/>micro-trade support</i>"]
decide{{"Decision"}}
poll --> dedup_check --> evaluate --> size --> decide
end
@@ -170,22 +185,30 @@ flowchart TB
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["<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
%% ── Risk Engine ───────────────────────────────────────────────
risk["<b>Risk Engine</b><br/><i>services.risk.app</i><br/>POST /evaluate<br/>Approval workflow"]
broker <-->|"evaluate order"| risk
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<br/>(inline call)"| risk
%% ── Alpaca ────────────────────────────────────────────────────
alpaca["<b>Alpaca</b><br/><i>Paper Trading API</i><br/>Order submission,<br/>position sync"]
broker <-->|"submit order /<br/>sync positions"| alpaca
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 /<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
%% ── 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 ─────────────────────────────────────────────
subgraph notifications ["Notifications"]
direction LR
@@ -198,28 +221,32 @@ flowchart TB
## 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
flowchart LR
%% ── Lake Publish Queue ────────────────────────────────────────
q_lake[["stonks:queue:lake_publish"]]
various(("Various Services<br/><i>ingestion, extractor,<br/>recommendation,<br/>broker adapter</i>"))
various -->|"enqueue_lake_job"| q_lake
various(("Upstream Services<br/><i>via enqueue_lake_job()</i>"))
various -->|"rpush job<br/>(job_type + entity_id)"| q_lake
%% ── 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
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
%% ── MinIO Parquet ─────────────────────────────────────────────
minio_lake[("MinIO<br/><i>Lakehouse Bucket</i><br/>Partitioned Parquet<br/>/year=/month=/day=")]
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["<b>Trino</b><br/><i>SQL Query Engine</i><br/>Hive connector → MinIO"]
minio_lake -->|"read via<br/>Hive Metastore"| trino
@@ -238,18 +265,40 @@ flowchart LR
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
| Queue | Full Key | Producer(s) | Consumer |
|-------|----------|-------------|----------|
| Ingestion | `stonks:queue:ingestion` | Scheduler | Ingestion Worker |
| Parsing | `stonks:queue:parsing` | Ingestion Worker | Parser Worker |
| Extraction | `stonks:queue:extraction` | Parser (standard docs) | Extractor Worker |
| Macro Classification | `stonks:queue:macro_classification` | Parser (macro_event docs), Scheduler | Extractor Worker |
| Aggregation | `stonks:queue:aggregation` | Extractor Worker | Aggregation Worker |
| Recommendation | `stonks:queue:recommendation` | Aggregation Worker | Recommendation Worker |
| Broker Orders | `stonks:queue:broker_orders` | Trading Engine, Trading API (manual overrides) | Broker Adapter |
| Lake Publish | `stonks:queue:lake_publish` | Various services | Lake Publisher |
| Ingestion | `stonks:queue:ingestion` | Scheduler (company, macro, global market sources) | Ingestion Worker |
| Parsing | `stonks:queue:parsing` | Ingestion Worker (news, filings, web_scrape, macro) | Parser Worker |
| Extraction | `stonks:queue:extraction` | Parser (standard docs), Scheduler (stale recovery) | Extractor Worker |
| Macro Classification | `stonks:queue:macro_classification` | Parser (macro_event docs), Scheduler (stale/failed recovery) | Extractor Worker |
| Aggregation | `stonks:queue:aggregation` | Extractor Worker (per ticker), Scheduler (periodic, all tickers) | Aggregation Worker |
| Recommendation | `stonks:queue:recommendation` | Aggregation Worker (ticker + window, 5 min dedup TTL) | Recommendation Worker |
| Broker Orders | `stonks:queue:broker_orders` | Trading Engine (act decisions), Trading API (manual overrides) | Broker Adapter |
| 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.
@@ -257,18 +306,25 @@ Dead-letter queues follow the pattern `stonks:dlq:<queue_name>` and are populate
| 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` |
| **Redis** | Queues, dedup markers, rate limits, circuit breaker state | `stonks:queue:*`, `stonks:dedupe:*`, `stonks:ratelimit:*`, `stonks:trading:circuit_breaker:*`, `stonks:dlq:*` |
| **MinIO** | Object storage for raw artifacts, normalized text, and analytical Parquet files | Raw artifacts bucket, normalized text bucket, lakehouse bucket (partitioned Parquet) |
| **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, 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, 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
| Integration | Service | Protocol | Purpose |
|-------------|---------|----------|---------|
| **Polygon.io** | Ingestion (via adapters) | HTTPS REST | News articles, market bars, grouped daily data |
| **SEC EDGAR** | Ingestion (via FilingsDataAdapter) | HTTPS REST | 10-K, 10-Q filings |
| **Ollama** | Extractor, Recommendation | HTTP `/api/generate` | LLM inference for document extraction, event classification, thesis rewriting |
| **Alpaca** | Broker Adapter | HTTPS REST | Paper trading order submission, position sync, account state |
| **Polygon.io** | Ingestion (via PolygonNewsAdapter, PolygonMarketAdapter) | HTTPS REST | News articles, market bars, grouped daily data, intraday bars |
| **SEC EDGAR** | Ingestion (via SECEdgarAdapter) | HTTPS REST | 10-K, 10-Q filings |
| **Macro News** | Ingestion (via MacroNewsAdapter) | HTTPS REST | Geopolitical and economic event articles |
| **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 |
| **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.
+2 -1
View File
@@ -53,7 +53,7 @@ graph TB
subgraph trading_tier ["Trading Tier"]
direction LR
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>"]
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) |
| `trino` | Trino container | superset (trino:8080) |
| `query-api` | Query API container | dashboard (nginx proxy upstream) |
| `risk` | risk-engine container (network alias) | trading-engine (risk evaluation calls) |
+67 -42
View File
@@ -11,7 +11,7 @@ graph TB
%% ── External traffic ──────────────────────────────────────────
internet((Internet))
subgraph traefik ["kube-system (Traefik Ingress Controller)"]
subgraph traefik ["kube-system · Traefik Ingress Controller"]
direction LR
ing_dash["stonks.celestium.life"]
ing_api["stonks-api.celestium.life"]
@@ -28,47 +28,55 @@ graph TB
direction TB
%% ── API Tier (ingress-facing) ─────────────────────────────
subgraph api_tier ["API Tier"]
subgraph api_tier ["API Tier · tier: api"]
direction LR
query_api["query-api<br/><i>Deployment (1 replica)</i><br/>:8000"]
symbol_registry["symbol-registry<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<br/><i>readiness: /docs · liveness: /docs</i>"]
end
%% ── Frontend Tier ─────────────────────────────────────────
subgraph frontend_tier ["Frontend Tier"]
dashboard["dashboard<br/><i>Deployment (1 replica)</i><br/>:8080<br/><i>nginx-unprivileged</i>"]
subgraph frontend_tier ["Frontend Tier · tier: frontend"]
dashboard["dashboard<br/><i>Deployment · 1 replica</i><br/>:8080<br/><i>nginx-unprivileged</i><br/><i>readiness: / · liveness: /</i>"]
end
%% ── Trading Tier ──────────────────────────────────────────
subgraph trading_tier ["Trading Tier"]
subgraph trading_tier ["Trading Tier · tier: trading"]
direction LR
trading_engine["trading-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>"]
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"]
broker_adapter["broker-adapter<br/><i>Deployment · 1 replica</i><br/><i>queue-driven worker · pipeline-gated</i>"]
end
%% ── Orchestration Tier ────────────────────────────────────
subgraph orchestration_tier ["Orchestration Tier"]
scheduler["scheduler<br/><i>Deployment (1 replica)</i><br/><i>runs migrations + seed</i>"]
subgraph orchestration_tier ["Orchestration Tier · tier: orchestration"]
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
%% ── Processing Tier (pipeline workers) ────────────────────
subgraph processing_tier ["Processing Tier (pipeline workers)"]
subgraph processing_tier ["Processing Tier · tier: processing"]
direction LR
ingestion["ingestion<br/><i>Deployment (2 replicas)</i>"]
parser["parser<br/><i>Deployment (2 replicas)</i>"]
extractor["extractor<br/><i>Deployment (1 replica)</i>"]
aggregation["aggregation<br/><i>Deployment (4 replicas)</i>"]
recommendation["recommendation<br/><i>Deployment (1 replica)</i>"]
parser["parser<br/><i>Deployment · 2 replicas · pipeline-gated</i>"]
extractor["extractor<br/><i>Deployment · 1 replica · pipeline-gated</i>"]
aggregation["aggregation<br/><i>Deployment · 4 replicas · pipeline-gated</i>"]
recommendation["recommendation<br/><i>Deployment · 1 replica · pipeline-gated</i>"]
end
%% ── Analytics Tier ────────────────────────────────────────
subgraph analytics_tier ["Analytics Tier"]
subgraph analytics_tier ["Analytics Tier · tier: analytics"]
direction LR
lake_publisher["lake-publisher<br/><i>Deployment (1 replica)</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>"]
trino["trino<br/><i>Deployment (1 replica)</i><br/>:8080<br/><i>trinodb/trino:latest</i>"]
superset["superset<br/><i>Deployment (1 replica)</i><br/>:8088<br/><i>custom image</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><br/><i>PVC: hive-metastore-data</i>"]
trino["trino<br/><i>Deployment · 1 replica</i><br/>:8080<br/><i>trinodb/trino:latest</i><br/><i>readiness: /v1/info</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
%% ── Helm Secrets ──────────────────────────────────────────
@@ -99,7 +107,7 @@ graph TB
end
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
%% ── Ingress Routes ────────────────────────────────────────────
@@ -191,6 +199,7 @@ graph TB
sec_broker -.-> broker_adapter
sec_market -.-> ingestion
sec_market -.-> query_api
sec_gmail -.-> trading_engine
@@ -216,7 +225,9 @@ graph TB
classDef tradingSvc fill:#e8a838,stroke:#b07d1a,color:#fff
classDef processSvc fill:#9b59b6,stroke:#6c3483,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 supersetSvc fill:#c0392b,stroke:#96281b,color:#fff
classDef extSvc fill:#95a5a6,stroke:#717d7e,color:#fff
classDef secretSvc fill:#f5f5dc,stroke:#999,color:#333
classDef configSvc fill:#dfe6e9,stroke:#999,color:#333
@@ -225,8 +236,10 @@ graph TB
class dashboard frontendSvc
class trading_engine,risk_engine,broker_adapter tradingSvc
class scheduler orchSvc
class ingestion,parser,extractor,aggregation,recommendation processSvc
class lake_publisher,hive_metastore,trino,superset analyticsSvc
class ingestion ingestionSvc
class parser,extractor,aggregation,recommendation processSvc
class lake_publisher,hive_metastore,trino analyticsSvc
class superset supersetSvc
class postgres,redis,minio,ollama extSvc
class sec_core,sec_broker,sec_market,sec_gmail,sec_dashboard secretSvc
class configmap configSvc
@@ -284,8 +297,8 @@ The following services have **no inbound network policy** — they are queue-dri
| Service | Tier | Behavior |
|---------|------|----------|
| scheduler | orchestration | Polls DB, enqueues to Redis |
| ingestion | processing | Reads from `stonks:queue:ingestion`, writes to DB/MinIO/Redis |
| scheduler | orchestration | Polls DB, enqueues to Redis. Runs migrations + seed + backfill as init containers |
| 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 |
| extractor | processing | Reads from `stonks:queue:extraction`, calls Ollama, 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
| Tier | Services | Ingress? | Replicas | Notes |
|------|----------|----------|----------|-------|
| **api** | query-api, symbol-registry | Yes (Traefik) | 1 each | FastAPI, readiness probes on `/docs` |
| **frontend** | dashboard | Yes (Traefik) | 1 | 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 |
| **orchestration** | scheduler | No | 1 | Runs DB migrations + seed as init containers |
| **processing** | ingestion, parser, extractor, aggregation, recommendation | No | 2, 2, 1, 4, 1 | Pipeline-gated by `pipelineEnabled` toggle |
| **analytics** | lake-publisher, trino, hive-metastore, superset | trino + superset: Yes; others: No | 1 each | lake-publisher is pipeline-gated |
| Tier | Services | Ingress? | Replicas | Pipeline-Gated? | Notes |
|------|----------|----------|----------|-----------------|-------|
| **api** | query-api, symbol-registry | Yes (Traefik) | 1 each | No | FastAPI, readiness probes on `/docs` |
| **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 | broker-adapter only | trading-engine has egress to Alpaca + Gmail |
| **orchestration** | scheduler | No | 1 | Yes | Runs DB migrations + seed + backfill as init containers |
| **ingestion** | ingestion | No | 1 | Yes | Fetches from external APIs (Polygon.io, news, filings) |
| **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 | 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-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-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:
1. **Lake Publisher** writes Parquet fact tables to MinIO at `s3a://stonks-lakehouse/warehouse`
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.
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.
4. **Superset** connects to Trino for lakehouse queries and to PostgreSQL for its metadata DB. Uses Redis for caching. Exposed externally via Traefik ingress.
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 (`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. 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. 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
@@ -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-dash.celestium.life` | superset | 8088 | `stonks-dash-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
View File
@@ -5,6 +5,7 @@ This guide covers running the full Stonks Oracle platform locally using Docker C
## Prerequisites
- 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)
- 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
git clone <repo-url> && cd stonks-oracle
# 2. Configure API keys
cp .env.example .env # or edit the existing .env
# Fill in MARKET_DATA_API_KEY, BROKER_API_KEY, BROKER_API_SECRET
# 2. Configure API keys (create .env in the repo root)
cat > .env <<'EOF'
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
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
# 5. Access the dashboard
# 7. Access the dashboard
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
@@ -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) |
| `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 | Service | Protocol |
@@ -109,15 +146,27 @@ The `.env` file is loaded by `ingestion`, `broker-adapter`, and `trading-engine`
```dotenv
# 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=
# Alpaca broker credentials (required for paper/live trading)
# ── Required for paper/live trading ──
BROKER_API_KEY=
BROKER_API_SECRET=
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 |
@@ -178,20 +227,24 @@ All application services support additional environment variables loaded via `se
| `REDIS_DB` | `0` | Redis database number |
| `REDIS_PASSWORD` | (none) | Redis password (not needed in Docker Compose) |
| `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_TIMEOUT` | `120` | Ollama request timeout (seconds) |
| `OLLAMA_MAX_RETRIES` | `2` | Max retries for Ollama requests |
| `VLLM_BASE_URL` | (empty) | vLLM server URL (if using vLLM instead of Ollama) |
| `VLLM_MODEL` | (empty) | vLLM model name (e.g. `AxionML/Qwen3.5-9B-NVFP4`) |
| `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 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_MAX_RETRIES` | `2` | Max retries for vLLM requests |
| `VLLM_TEMPERATURE` | `0.7` | vLLM sampling temperature |
| `VLLM_MAX_TOKENS` | `4096` | vLLM max output tokens |
| `VLLM_API_KEY` | (empty) | vLLM API key (if required) |
| `TRINO_HOST` | `localhost` | Trino hostname |
| `TRINO_PORT` | `8080` | Trino port |
| `TRINO_CATALOG` | `lakehouse` | Trino catalog 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_PROVIDER` | `polygon` | Market data provider |
| `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_POLLING_INTERVAL_SECONDS` | `60` | Recommendation polling interval |
| `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_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_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 |
| `JSON_LOGS` | `true` | Enable structured JSON logging |
| `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.
@@ -217,7 +320,7 @@ Stonks Oracle supports two LLM backends: **Ollama** (local, self-hosted) and **v
### 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
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.
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
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"
```
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
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
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_TEMPERATURE=0.7
```
@@ -268,7 +373,7 @@ VLLM_TEMPERATURE=0.7
Then update the `ai_agents` table to use the vLLM provider:
```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:
@@ -276,7 +381,7 @@ Or use the API:
```bash
curl -X PUT http://localhost:8004/api/admin/agents/document-extractor \
-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)
@@ -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:
```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 = '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 = 'document-extractor';
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';
```
@@ -293,19 +398,21 @@ Both `OLLAMA_BASE_URL` and `VLLM_BASE_URL` must be set in the environment for mi
### 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
# Auto-detect host Ollama, use default model
# Deploy with defaults (Docker Ollama, GPU-accelerated)
bash deploy-docker.sh
# Specify a remote Ollama instance
bash deploy-docker.sh --ollama-url http://10.1.1.12:2701 --ollama-model qwen3.6
# Specify a custom model
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
```
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
@@ -404,6 +511,9 @@ docker compose ps query-api
# Inspect health check details for a container
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.
**Base image**: `python:3.12-slim`
**Base image**: `python:3.12-slim` (via Harbor proxy cache in CI)
**Build arguments**:
| Argument | Default | Description |
|----------|---------|-------------|
| `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**:
- `requirements.txt` → pip dependencies installed
- `services/` → all service source code
- `scripts/` → operational scripts
- `tests/` → test files (available for in-container testing)
- `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.
**Base image**: `apache/superset:latest`
**Base image**: `apache/superset:latest` (via Harbor proxy cache in CI)
**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`):
- Serves the built static files on port 8080
- 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
@@ -503,6 +617,9 @@ docker build -t my-dashboard \
# Rebuild all images
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
docker compose up -d
# Start all services and wait for health checks
docker compose up -d --wait
# Start only infrastructure (useful for local development)
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
docker compose exec postgres psql -U stonks -d stonks
# Seed the database
docker compose exec scheduler python -m services.symbol_registry.seed
```
### Full Reset
@@ -680,13 +803,16 @@ The dashboard container runs nginx with reverse proxy rules that route API reque
| Path | Proxied To | Service |
|------|-----------|---------|
| `/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 |
| `/risk/` | `http://risk:8000/` | Risk Engine (via network alias) |
| `/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.
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.
### 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
If a port is already in use, modify the host port mapping in `docker-compose.yml`:
@@ -743,3 +882,15 @@ query-api:
ports:
- "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
View File
@@ -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>`). |
| `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`. |
| `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`. |
| `secrets` | list(string) | no | List of Secret names to mount via `envFrom.secretRef`. |
| `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 |
| `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.
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
@@ -141,7 +142,7 @@ The scheduler deployment has two init containers (not configurable via values):
| Field | Value |
|-------|-------|
| `replicas` | `2` |
| `replicas` | `1` |
| `pipeline` | `true` |
| `image` | `ingestion` |
| `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` |
| `tier` | `api` |
| `port` | `8000` |
| `secrets` | `stonks-core-secrets` |
| `secrets` | `stonks-core-secrets`, `stonks-market-secrets` |
| `resources.requests` | cpu: 100m, memory: 128Mi |
| `resources.limits` | cpu: 500m, memory: 256Mi |
| `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 |
|-----|------|---------|-------------|
| `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_TIMEOUT` | string | `240` | Request timeout in seconds for Ollama API calls. |
| `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_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
| 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_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.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`. |
@@ -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_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.01.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_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. |
@@ -395,7 +407,7 @@ All keys under `config` are rendered into a Kubernetes ConfigMap named `stonks-c
## `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
@@ -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-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-dashboard-secrets` | `secrets.dashboard` | superset |
### `secrets.core`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `POSTGRES_PASSWORD` | string | `""` | PostgreSQL password. |
| `MINIO_ACCESS_KEY` | string | `""` | MinIO access key (AWS-style). |
| `MINIO_SECRET_KEY` | string | `""` | MinIO secret key. |
| `REDIS_PASSWORD` | string | `""` | Redis authentication password. |
| Key | Type | Description |
|-----|------|-------------|
| `POSTGRES_PASSWORD` | string | PostgreSQL password. |
| `MINIO_ACCESS_KEY` | string | MinIO access key (AWS-style). |
| `MINIO_SECRET_KEY` | string | MinIO secret key. |
| `REDIS_PASSWORD` | string | Redis authentication password. |
### `secrets.broker`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `BROKER_API_KEY` | string | `""` | Broker API key (e.g., Alpaca paper trading key). |
| `BROKER_API_SECRET` | string | `""` | Broker API secret. |
| `BROKER_BASE_URL` | string | `""` | Broker API base URL (e.g., `https://paper-api.alpaca.markets`). |
| Key | Type | Description |
|-----|------|-------------|
| `BROKER_API_KEY` | string | Broker API key (e.g., Alpaca paper trading key). |
| `BROKER_API_SECRET` | string | Broker API secret. |
| `BROKER_BASE_URL` | string | Broker API base URL (e.g., `https://paper-api.alpaca.markets`). |
### `secrets.market`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `MARKET_DATA_API_KEY` | string | `""` | Market data provider API key (e.g., Polygon.io). |
| Key | Type | Description |
|-----|------|-------------|
| `MARKET_DATA_API_KEY` | string | Market data provider API key (e.g., Polygon.io). |
### `secrets.gmail`
@@ -440,10 +452,10 @@ Secrets are rendered into five Kubernetes Secret objects. In the base `values.ya
### `secrets.dashboard`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `SUPERSET_SECRET_KEY` | string | `""` | Flask secret key for Superset session encryption. |
| `SUPERSET_ADMIN_PASSWORD` | string | `""` | Superset admin user password. |
| Key | Type | Description |
|-----|------|-------------|
| `SUPERSET_SECRET_KEY` | string | Flask secret key for Superset session encryption. |
| `SUPERSET_ADMIN_PASSWORD` | string | Superset admin user password. |
### 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`. |
| `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_USER` | `stonks_beta` | Separate database user for beta. |
| `config.REDIS_DB` | `1` | Separate Redis DB index. |
| `config.LOG_LEVEL` | `DEBUG` | Verbose logging for debugging. |
| `config.TRADING_ENABLED` | `false` | Safety net — no order submission in beta. |
| `config.PIPELINE_DEFAULT_OFF` | `true` | Scheduler won't enqueue jobs unless explicitly enabled. |
| `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 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. |
| `trino.enabled` | `false` | Analytics stack disabled in beta. |
| `hiveMetastore.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:
| Service | Hostname |
@@ -649,11 +666,11 @@ Paper ingress hostnames:
```
values-beta.yaml values-paper.yaml values.yaml (base)
Beta Paper Trading Production
Beta -> Paper Trading -> Production
Integration Simulated orders Live trading
testing Real market data Real orders
Pipeline OFF Pipeline ON Pipeline ON
Trading OFF Trading ON Trading ON
Trading ON Trading ON Trading ON
Analytics OFF Analytics ON Analytics ON
```
+2 -1
View File
@@ -20,7 +20,7 @@ scrape_configs:
scrape_interval: 15s
scrape_timeout: 10s
metrics_path: /metrics
static_targets:
static_configs:
- targets:
# Docker Compose
- "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_filled_total` | Counter | `side` | Orders filled by broker |
| `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_check_failures_total` | Counter | `check_name` | Individual risk check failures |
| `stonks_positions_synced_total` | Counter | — | Position sync operations completed |
+117 -10
View File
@@ -41,6 +41,7 @@ All queues use the `stonks:queue:<name>` key pattern (configurable via `DEPLOY_S
| `recommendation` | `stonks:queue:recommendation` | Aggregation | Recommendation |
| `broker_orders` | `stonks:queue:broker_orders` | Trading Engine, Trading API | Broker Adapter |
| `lake_publish` | `stonks:queue:lake_publish` | Various services | Lake Publisher |
| `report_generation` | `stonks:queue:report_generation` | Scheduler | Scheduler (inline consumer) |
### 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
**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`
@@ -176,12 +186,16 @@ All queues use the `stonks:queue:<name>` key pattern (configurable via `DEPLOY_S
| `recommendations` | Write (delete) | Retention cleanup |
| `order_events` | 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
| Direction | Queue | Purpose |
|---|---|---|
| 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/Write | `stonks:lock:scheduler_cycle` | Distributed lock for single-writer |
| 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.
- **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 (1490 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
- `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-normalized` — Normalized text (written by parser)
@@ -296,6 +312,13 @@ None — this service is purely HTTP-driven.
| `broker` | `AlpacaBrokerAdapter` | Alpaca |
| `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
@@ -349,7 +372,7 @@ None — this service is purely HTTP-driven.
## 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`
@@ -363,9 +386,16 @@ None — this service is purely HTTP-driven.
| `REDIS_*` | _(see shared)_ | Redis connection |
| `MINIO_*` | _(see shared)_ | MinIO connection |
| `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_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 |
| `LOG_LEVEL` | `INFO` | Logging level |
@@ -395,6 +425,7 @@ None — this service is purely HTTP-driven.
### 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
- Resolves agent configuration from DB with 60-second TTL cache (`AgentConfigResolver`)
- 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 |
| `circuit_breaker_events` | Read/Write | Circuit breaker trigger/reset events |
| `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 |
| `backtest_runs` | Read/Write | Backtest configuration and results |
| `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 |
| `broker_accounts` | Write (upsert) | Register/update broker account |
| `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 |
| `audit_events` | Write | Full audit trail |
@@ -728,7 +759,7 @@ None — called synchronously by the broker adapter and via HTTP.
## 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)
@@ -745,6 +776,7 @@ None — called synchronously by the broker adapter and via HTTP.
| `TRINO_PORT` | `8080` | Trino port |
| `TRINO_CATALOG` | `lakehouse` | Trino catalog |
| `TRINO_SCHEMA` | `stonks` | Trino schema |
| `TRINO_ICEBERG_CATALOG` | `iceberg` | Trino Iceberg catalog |
| `LOG_LEVEL` | `INFO` | Logging level |
### Database Tables
@@ -757,9 +789,9 @@ The Query API reads from nearly all tables in the database, including:
| `sources` | Source configurations |
| `documents`, `document_company_mentions` | Document timelines |
| `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 |
| `risk_evaluations` | Risk evaluation results |
| `risk_evaluations`, `risk_configs` | Risk evaluation results and configuration |
| `orders`, `order_events` | Order history and lifecycle |
| `positions`, `portfolio_snapshots` | Portfolio state |
| `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 |
| `market_snapshots` | Market price 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
@@ -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 | `stonks:queue:*` | Queue depth monitoring for DLQ and DevOps metrics |
| Read | `stonks:dlq:*` | Dead-letter queue inspection and replay |
| Read | `stonks:ratelimit:*` | Rate limit status monitoring |
### Key Behaviors
- Exposes `/metrics` endpoint for Prometheus scraping
- 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)
- Pipeline control (enable/disable via Redis toggle)
- 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_TIMEOUT` | `120` | Request timeout (seconds) |
| `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
+11
View File
@@ -127,6 +127,17 @@ services:
requests: { cpu: 50m, memory: 64Mi }
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:
replicas: 1
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);
+1
View File
@@ -70,6 +70,7 @@ QUEUE_BROKER = "broker_orders"
QUEUE_MACRO_CLASSIFICATION = "macro_classification"
QUEUE_REPORT_GENERATION = "report_generation"
QUEUE_REPORT_GENERATION = "report_generation"
QUEUE_SIGNAL_ENGINE = "signal_engine"
# --- Trading engine ---
QUEUE_TRADING_DECISIONS = "trading_decisions"
+1
View File
@@ -0,0 +1 @@
# Signal Engine - dual-pipeline signal evaluation (heuristic + probabilistic)
+355
View File
@@ -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
+136
View File
@@ -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
+137
View File
@@ -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]
+139
View File
@@ -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,
)
+154
View File
@@ -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)
+233
View File
@@ -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,
)
+80
View File
@@ -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)
+299
View File
@@ -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,
)
+180
View File
@@ -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())
+271
View File
@@ -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
+459
View File
@@ -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,
)
+107
View File
@@ -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,
)
+380
View File
@@ -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.16.9, 14.114.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.17.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)
+127
View File
@@ -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 1233%.
- 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)),
}
+127
View File
@@ -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,
},
)
+182
View File
@@ -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,
},
)
+149
View File
@@ -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 (3070).
"""
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
+300
View File
@@ -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
+178
View File
@@ -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}"
)
+177
View File
@@ -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}"
)
+133
View File
@@ -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})"
)
+72
View File
@@ -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}"
)
+222
View File
@@ -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}"
)
+144
View File
@@ -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)}"
)
+200
View File
@@ -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
+326
View File
@@ -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
+388
View File
@@ -0,0 +1,388 @@
"""Unit tests for the multi-timeframe confluence engine.
Validates compute_confluence against requirements 3.13.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
+312
View File
@@ -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
+425
View File
@@ -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"
+304
View File
@@ -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
+497
View File
@@ -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
+288
View File
@@ -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
+574
View File
@@ -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
+200
View File
@@ -0,0 +1,200 @@
"""Unit tests for the hard filter engine.
Validates evaluate_hard_filters against requirements 4.14.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 == []
+814
View File
@@ -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
+237
View File
@@ -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
+419
View File
@@ -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
+358
View File
@@ -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.16.9, 14.114.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
+346
View File
@@ -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
+592
View File
@@ -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()