Deploy to Talos K8s: add Market Analysis and Docker/K8s manifests

This commit is contained in:
Deep Koluguri 2026-06-22 15:49:06 -04:00
parent 7ec16dc655
commit 4774890415
52 changed files with 7187 additions and 889 deletions

View File

@ -0,0 +1,23 @@
name: Build Institutional Trader
on: [push]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
DOCKER_HOST: tcp://172.17.0.1:2375
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Build and push (Kaniko)
run: |
JOB_CONTAINER=$(docker ps --format '{{.Names}}' | grep 'GITEA-ACTIONS-TASK' | head -1)
docker run --rm \
--volumes-from "$JOB_CONTAINER" \
gcr.io/kaniko-project/executor:latest \
--context=dir://"$GITHUB_WORKSPACE" \
--dockerfile="$GITHUB_WORKSPACE/Dockerfile" \
--destination=192.168.8.250:5000/market:latest \
--insecure \
--skip-tls-verify

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
# Stage 1: Build Frontend
FROM node:18-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Stage 2: Build Backend & Serve
FROM node:18-alpine
WORKDIR /app/backend
# Install backend dependencies
COPY backend/package*.json ./
RUN npm install --production
# Copy backend source
COPY backend/ ./
# Copy built frontend to the public directory expected by server.js
# (__dirname is /app/backend/src, so ../../public is /app/public)
COPY --from=frontend-build /app/frontend/dist /app/public
# Set production environment
ENV NODE_ENV=production
ENV PORT=3010
EXPOSE 3010
CMD ["node", "src/server.js"]

View File

@ -58,7 +58,14 @@ USE_PYTHON_SERVICE=true
### Terminal 1: Python Service ### Terminal 1: Python Service
```bash ```bash
cd backend/python_service cd backend/python_service
source venv/bin/activate # or venv\Scripts\activate on Windows # Activate virtual environment:
# Git Bash on Windows:
source venv/Scripts/activate
# PowerShell/CMD on Windows:
venv\Scripts\activate
# Linux/macOS:
source venv/bin/activate
uvicorn main:app --reload --port 8010 uvicorn main:app --reload --port 8010
``` ```

View File

@ -22,7 +22,8 @@
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"pg": "^8.11.3", "pg": "^8.11.3",
"ws": "^8.14.2" "ws": "^8.14.2",
"xml2js": "^0.6.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"
@ -1499,6 +1500,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -1895,6 +1905,28 @@
} }
} }
}, },
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -36,7 +36,8 @@
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"pg": "^8.11.3", "pg": "^8.11.3",
"ws": "^8.14.2" "ws": "^8.14.2",
"xml2js": "^0.6.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"

View File

@ -0,0 +1,86 @@
{
"success": true,
"data": [
{
"CreatedDate": "2025-01-15",
"CreatedTime": "10:30:45 AM",
"Symbol": "(5🟩) AAPL · RTH ⚡ 🟢💎⭐💰 ⚡ 🔥",
"Rocket": "🚀🚀🚀 [ITM 2.5%]",
"NetPremium": "1.50 M",
"Premium": "850.00 K",
"premium_num": 850000,
"bull_total": 1500000,
"bear_total": 50000,
"PctVsPriorClose": 1.2,
"PctVsRthOpen": 0.8,
"Pct5m": 0.3,
"Pct15m": 0.5,
"TapeAlign": "↗︎",
"NearAlert": "EARNINGS",
"ExpirationDate": "2025-01-17",
"Price": "185.50",
"CallPut": "CALL",
"Side": "AA",
"Strike": "185",
"Spot": "185.50",
"Volume": "5000",
"OI": "3000",
"direction": "BULL",
"badge_round": "🟢",
"badge_more": "💎⭐💰✔",
"flash": "⚡",
"rocket_score": 5.2,
"session_bucket": "RTH",
"flow_ts_utc": "2025-01-15T16:30:45+00:00",
"mny_pct": 2.5,
"vwap_at_signal": 185.20,
"price_vs_vwap_pct": 0.16,
"signal_tier": "TIER_1",
"checklist_score": 8.5,
"checklist_passed": true,
"premium_zscore": 2.3,
"premium_percentile_intraday": 85.5,
"relative_premium_score": 72.5,
"aggression_score": 68.0,
"size_concentration_score": 75.0,
"repeat_trade_velocity": 55.0,
"strike_clustering_score": 60.0,
"signal_strength": 64.7,
"early_noise_reject": false,
"flow_acceleration": 12500.5,
"time_between_hits": 3.2,
"follow_on_ratio": 0.75,
"strike_laddering_detected": true,
"delta_exposure": 92500000,
"gamma_exposure": 1250000000,
"volatility_intent": "LONG_VOL",
"net_gamma_exposure_per_symbol": 8500000000,
"gamma_flip_proximity": 0.15,
"dealer_hedge_pressure_score": 72.5,
"market_regime": "TREND",
"flow_state": "ACTIONABLE",
"confidence_score": 78.5,
"institutional_likelihood": 0.85,
"dealer_pain_level": 65.0,
"expected_move_vs_implied": 1.35
}
],
"count": 1,
"timestamp": "2025-01-15T16:35:00.123456"
}

View File

@ -0,0 +1,168 @@
# Institutional-Grade Options Flow Analytics
This document describes the institutional-grade enhancements to the options flow pipeline.
## Overview
The pipeline has been refactored to convert static retail-style flow detection into dynamic, dealer-aware, time-sequenced signals suitable for intraday momentum and 1-5 day swing trades.
## New Analytics Modules
### 1. Relative Premium Scoring (`relative_premium_scorer.py`)
**Purpose**: Replace static premium filter (minPremium = $80K) with context-aware relative scoring.
**New Fields**:
- `premium_zscore`: Z-score of premium relative to 20-day rolling window per ticker
- `premium_percentile_intraday`: Percentile rank within same-day flow
- `relative_premium_score`: Composite score (0-100) combining z-score, intraday percentile, and median normalization
**Usage**: Premium of $80K might be significant for AAPL but noise for TSLA. This module computes relative significance.
### 2. Signal Component Scoring (`signal_component_scorer.py`)
**Purpose**: Convert binary badge logic (💎 ⭐ 🟢 🔴) into continuous numeric signal components.
**New Fields**:
- `aggression_score`: Measures trade aggression (ITM premiums, ask-side trades)
- `size_concentration_score`: Measures size concentration (single large trade vs many small ones)
- `repeat_trade_velocity`: Measures repeat trade frequency (urgency building)
- `strike_clustering_score`: Measures strike clustering (laddering patterns)
- `signal_strength`: Composite score = 0.30 * aggression + 0.30 * size_concentration + 0.20 * repeat_velocity + 0.20 * strike_clustering
**Note**: Badges remain display-only. Signal strength is computed from components.
### 3. Tier-0 Noise Rejection (`noise_rejector.py`)
**Purpose**: Reject low-quality signals before enrichment to reduce processing overhead.
**New Fields**:
- `early_noise_reject`: Boolean flag indicating if signal should be rejected as noise
**Rejection Criteria**:
- Single isolated trade (no repeat activity within 30 minutes)
- Far OTM weekly lottos (>15% OTM with <7 days to expiry)
- Delta-adjusted premium below threshold (<$50K)
### 4. Time-Sequenced Flow Analysis (`time_sequenced_analyzer.py`)
**Purpose**: Analyze flow patterns over time to detect urgency, distribution, and continuation.
**New Fields**:
- `flow_acceleration`: Change in premium per minute (Δ premium / minute)
- `time_between_hits`: Average time between consecutive trades (minutes)
- `follow_on_ratio`: Fraction of trades in same direction after initial trade (0-1)
- `strike_laddering_detected`: Boolean indicating sequential strike accumulation
**Interpretation**:
- Escalating premium + decreasing time gaps = urgency
- Flat premium + widening gaps = distribution
### 5. Intent Classification (`intent_classifier.py`)
**Purpose**: Replace naive direction (BULL/BEAR) with nuanced volatility and hedging intent.
**New Fields**:
- `delta_exposure`: Delta exposure (contracts * delta * 100 * spot_price)
- `gamma_exposure`: Gamma exposure (contracts * gamma * 100 * spot_price^2)
- `volatility_intent`: Enum (LONG_VOL, SHORT_VOL, DIRECTIONAL, HEDGE_UNWIND)
**Note**: Direction (BULL/BEAR) becomes secondary metadata.
### 6. Dealer-Aware Flow Context (`dealer_flow_context.py`)
**Purpose**: Track dealer hedging pressure and gamma exposure.
**New Fields**:
- `net_gamma_exposure_per_symbol`: Sum of gamma exposures for symbol (positive = long gamma, negative = short gamma)
- `gamma_flip_proximity`: Proximity to gamma flip point (-1 to 1)
- `dealer_hedge_pressure_score`: Dealer hedge pressure score (0-100)
**Usage**: Validates flow continuation, flow reversals, and gamma squeeze setups.
### 7. Market Regime Detection (`market_regime_detector.py`)
**Purpose**: Identify market regime to gate trade signal generation.
**New Fields**:
- `market_regime`: Enum (TREND, RANGE, HIGH_VOL_EVENT)
**Trade Signal Gating**:
- Trend → continuation bias
- Range → fade or vol-sell bias
- Event → volatility expansion bias
### 8. Flow Decay & Reversal Validation (`flow_decay_validator.py`)
**Purpose**: Validate flow decay/reversal signals with anchors.
**New Fields**:
- `flow_state`: Enum (ACTIONABLE, INFORMATIONAL)
**Validation Criteria**: Flow decay/reversal is actionable ONLY IF:
- Premium contracts (relative_premium_score >= 60)
- Dealer hedge pressure decreases
- Price fails near VWAP / opening range / key level
- Otherwise marked as INFORMATIONAL
### 9. Institutional Confidence Metrics (`institutional_confidence.py`)
**Purpose**: Calculate confidence scores for institutional flow signals.
**New Fields**:
- `confidence_score`: Overall confidence score (0-100)
- `institutional_likelihood`: Likelihood flow is institutional (0-1)
- `dealer_pain_level`: Dealer pain level (0-100)
- `expected_move_vs_implied`: Expected move vs implied move ratio
## Integration
All modules are integrated into the main processing pipeline in `main.py`:
1. Basic flow processing (normalization, badges, rocket score)
2. Price context enrichment
3. Alert matching
4. **Institutional analytics pipeline** (NEW):
- Tier-0 noise rejection
- Relative premium scoring
- Signal component scoring
- Time-sequenced analysis
- Intent classification
- Dealer flow context
- Market regime detection
- Flow decay validation
- Confidence metrics
5. Filtering (premium, relative premium, badges, direction)
6. Output formatting
## Filtering Changes
**Before**:
- Static premium filter: `premium_num > 80000`
- Badge requirements: 🟢/🔴 + 💎 + ⭐
**After**:
- Static premium filter: `premium_num > min_premium` (still applied)
- **Relative premium filter**: `relative_premium_score >= 60.0` (NEW)
- **Noise rejection filter**: `early_noise_reject == False` (NEW)
- Badge requirements: 🟢/🔴 + 💎 + ⭐ (still applied, but badges are now display-only)
## API Response
All new fields are included in the API response. The response maintains backward compatibility - existing fields remain unchanged, new fields are additive.
## Design Philosophy
1. **Flow represents pressure, not prediction**: Signals indicate who is forced to act next (dealers hedging)
2. **Institutions trade urgency and forced hedging**: Focus on dealer pain and gamma exposure
3. **Fewer, higher-quality signals > more alerts**: Noise rejection and relative premium filtering reduce false positives
4. **Every signal must answer**: "Who is forced to act next?"
## Success Criteria
If implemented correctly:
- Signal count decreases (noise filtered out)
- Average signal quality increases (relative premium, signal strength)
- False positives reduce (noise rejection, dealer context validation)
- Trades align with intraday momentum and short-term swing horizons (time-sequenced analysis)

View File

@ -192,6 +192,63 @@ async def get_options_flow(
# Recalculate rocket score with price context and alerts # Recalculate rocket score with price context and alerts
df_final = processor.process_rocket_score(df_final) df_final = processor.process_rocket_score(df_final)
# Apply institutional-grade analytics pipeline
logger.info("🔹 Applying institutional-grade analytics...")
from services.relative_premium_scorer import RelativePremiumScorer
from services.noise_rejector import NoiseRejector
from services.signal_component_scorer import SignalComponentScorer
from services.time_sequenced_analyzer import TimeSequencedAnalyzer
from services.intent_classifier import IntentClassifier
from services.dealer_flow_context import DealerFlowContext
from services.market_regime_detector import MarketRegimeDetector
from services.flow_decay_validator import FlowDecayValidator
from services.institutional_confidence import InstitutionalConfidence
# 1. Tier-0 Noise Rejection (mark but don't filter yet - filtering happens later)
logger.info("1⃣ Applying tier-0 noise rejection...")
noise_rejector = NoiseRejector()
df_final = noise_rejector.mark_noise_rejections(df_final)
# 2. Relative Premium Scoring
logger.info("2⃣ Calculating relative premium scores...")
premium_scorer = RelativePremiumScorer(pool)
df_final = await premium_scorer.enrich_with_relative_premium(df_final)
# 3. Signal Component Scoring (convert badges to continuous scores)
logger.info("3⃣ Converting badges to continuous signal components...")
signal_scorer = SignalComponentScorer()
df_final = signal_scorer.enrich_with_signal_components(df_final)
# 4. Time-Sequenced Flow Analysis
logger.info("4⃣ Analyzing time-sequenced flow patterns...")
time_analyzer = TimeSequencedAnalyzer()
df_final = time_analyzer.enrich_with_time_sequenced_metrics(df_final)
# 5. Intent Classification
logger.info("5⃣ Classifying volatility and hedging intent...")
intent_classifier = IntentClassifier()
df_final = intent_classifier.enrich_with_intent_classification(df_final)
# 6. Dealer-Aware Flow Context
logger.info("6⃣ Analyzing dealer hedging pressure...")
dealer_context = DealerFlowContext()
df_final = dealer_context.enrich_with_dealer_context(df_final)
# 7. Market Regime Detection
logger.info("7⃣ Detecting market regime...")
regime_detector = MarketRegimeDetector()
df_final = regime_detector.enrich_with_market_regime(df_final)
# 8. Flow Decay & Reversal Validation
logger.info("8⃣ Validating flow decay and reversal signals...")
flow_validator = FlowDecayValidator()
df_final = flow_validator.enrich_with_flow_state(df_final)
# 9. Institutional Confidence Metrics
logger.info("9⃣ Calculating institutional confidence metrics...")
confidence_calc = InstitutionalConfidence()
df_final = confidence_calc.enrich_with_confidence_metrics(df_final)
# Phase 1 Enhancements (BEFORE filtering so all signals get Phase 1 data): # Phase 1 Enhancements (BEFORE filtering so all signals get Phase 1 data):
# Initialize Phase 1 columns with None/empty values first # Initialize Phase 1 columns with None/empty values first
if not df_final.empty: if not df_final.empty:
@ -259,6 +316,13 @@ async def get_options_flow(
# Filter by minimum premium and badge requirements (matching SQL WHERE clause) # Filter by minimum premium and badge requirements (matching SQL WHERE clause)
logger.info(f"📊 Before filtering: {len(df_final)} rows") logger.info(f"📊 Before filtering: {len(df_final)} rows")
# Apply noise rejection filter first (exclude early_noise_reject = True)
if 'early_noise_reject' in df_final.columns:
before_noise = len(df_final)
df_final = df_final[~df_final['early_noise_reject']].copy()
after_noise = len(df_final)
logger.info(f"📊 After noise rejection filter: {after_noise} rows (removed {before_noise - after_noise})")
# Only filter if columns exist # Only filter if columns exist
if 'premium_num' in df_final.columns: if 'premium_num' in df_final.columns:
before_premium = len(df_final) before_premium = len(df_final)
@ -268,6 +332,14 @@ async def get_options_flow(
else: else:
logger.warning("⚠️ premium_num column not found, skipping premium filter") logger.warning("⚠️ premium_num column not found, skipping premium filter")
# Apply relative premium filter if available
if 'relative_premium_score' in df_final.columns:
before_relative = len(df_final)
min_relative_threshold = 60.0 # Configurable threshold
df_final = df_final[df_final['relative_premium_score'] >= min_relative_threshold].copy()
after_relative = len(df_final)
logger.info(f"📊 After relative premium filter (>={min_relative_threshold}): {after_relative} rows (removed {before_relative - after_relative})")
if df_final.empty: if df_final.empty:
logger.warning("⚠️ No data after premium filter") logger.warning("⚠️ No data after premium filter")
return OptionsFlowResponse( return OptionsFlowResponse(

View File

@ -0,0 +1,256 @@
"""
Dealer-Aware Flow Context Service
Tracks dealer hedging pressure, gamma exposure, and flow continuation patterns
"""
import pandas as pd
import numpy as np
from typing import Dict, Optional
from datetime import timedelta
from utils.logger import logger
class DealerFlowContext:
"""
Analyzes flow from dealer perspective:
- Net gamma exposure per symbol
- Gamma flip proximity (when dealers become long/short gamma)
- Dealer hedge pressure (forced hedging activity)
"""
def __init__(self):
# Configuration
self.analysis_window_minutes = 120 # Look back window for gamma tracking
self.gamma_flip_threshold = 0.3 # Gamma flip when net gamma crosses threshold
def calculate_net_gamma_exposure_per_symbol(
self,
df: pd.DataFrame,
symbol: str,
timestamp: pd.Timestamp
) -> float:
"""
Calculate net gamma exposure for a symbol at a given time.
Sums all gamma exposures from recent flow.
Positive = dealers are long gamma (hedging by selling on rallies, buying on dips)
Negative = dealers are short gamma (hedging by buying on rallies, selling on dips)
"""
if df.empty:
return 0.0
# Look at flow up to this timestamp
window_start = timestamp - timedelta(minutes=self.analysis_window_minutes)
mask = (
(df['symbol_norm'] == symbol.upper()) &
(df['flow_ts_utc'] >= window_start) &
(df['flow_ts_utc'] <= timestamp) &
(df['gamma_exposure'].notna())
)
recent_flow = df[mask]
if recent_flow.empty:
return 0.0
# Sum gamma exposures
net_gamma = recent_flow['gamma_exposure'].sum()
return float(net_gamma)
def calculate_gamma_flip_proximity(
self,
df: pd.DataFrame,
row_idx: int
) -> Optional[float]:
"""
Calculate proximity to gamma flip (when dealers switch from long to short gamma or vice versa).
Returns: -1.0 to 1.0
- Positive = approaching long gamma (dealers becoming volatility buyers)
- Negative = approaching short gamma (dealers becoming volatility sellers)
- 0.0 = near flip point
"""
if row_idx >= len(df):
return None
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
flow_ts_utc = current_row.get('flow_ts_utc')
gamma_exposure = current_row.get('gamma_exposure', 0) or 0
if pd.isna(symbol) or pd.isna(flow_ts_utc):
return None
# Calculate current net gamma
net_gamma = self.calculate_net_gamma_exposure_per_symbol(df, symbol, flow_ts_utc)
# Normalize to -1 to 1 scale
# Use exponential scaling to emphasize near-flip conditions
if net_gamma == 0:
return 0.0
# Simple normalization: divide by a large threshold
# More sophisticated: use percentile or adaptive scaling
threshold = 1000000000 # 1B in gamma exposure as normalization factor
normalized = net_gamma / threshold
# Clamp to -1 to 1
normalized = max(-1.0, min(1.0, normalized))
# Invert: negative normalized = positive proximity (approaching long gamma)
# This is because dealer short gamma (negative) means they're selling volatility
return float(-normalized)
def calculate_dealer_hedge_pressure_score(
self,
df: pd.DataFrame,
row_idx: int
) -> float:
"""
Calculate dealer hedge pressure score (0-100).
Higher score = more forced hedging by dealers:
- High net gamma exposure (dealers must hedge)
- Recent flow creating gamma imbalance
- Flow continuation (dealers hedging creates more flow)
"""
if row_idx >= len(df):
return 0.0
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
flow_ts_utc = current_row.get('flow_ts_utc')
gamma_exposure = current_row.get('gamma_exposure', 0) or 0
if pd.isna(symbol) or pd.isna(flow_ts_utc):
return 0.0
score = 0.0
# Net gamma component (0-40 points)
# High absolute net gamma = more hedge pressure
net_gamma = abs(self.calculate_net_gamma_exposure_per_symbol(df, symbol, flow_ts_utc))
if net_gamma > 0:
# Normalize: 500M = 20 points, 1B = 40 points
normalized_gamma = min(1.0, net_gamma / 1000000000) # 1B threshold
score += normalized_gamma * 40.0
# Recent gamma accumulation (0-30 points)
# Recent flow creating gamma imbalance
window_start = flow_ts_utc - timedelta(minutes=30)
mask = (
(df['symbol_norm'] == symbol.upper()) &
(df['flow_ts_utc'] >= window_start) &
(df['flow_ts_utc'] <= flow_ts_utc) &
(df['gamma_exposure'].notna())
)
recent_gamma = df[mask]['gamma_exposure'].sum()
if abs(recent_gamma) > 0:
# Normalize recent gamma accumulation
normalized_recent = min(1.0, abs(recent_gamma) / 500000000) # 500M threshold
score += normalized_recent * 30.0
# Flow continuation component (0-30 points)
# If flow is continuing in same direction, dealers are likely hedging
follow_on_ratio = current_row.get('follow_on_ratio')
if follow_on_ratio is not None and not pd.isna(follow_on_ratio):
# High follow-on ratio = continuation = hedge pressure
score += follow_on_ratio * 30.0
return min(100.0, max(0.0, score))
def validate_flow_continuation(
self,
df: pd.DataFrame,
row_idx: int
) -> bool:
"""
Validate if flow continuation is likely based on dealer hedge pressure.
Returns True if continuation is expected (dealers forced to hedge).
"""
current_row = df.iloc[row_idx]
dealer_pressure = current_row.get('dealer_hedge_pressure_score', 0) or 0
net_gamma = current_row.get('net_gamma_exposure_per_symbol', 0) or 0
# High dealer pressure + significant gamma exposure = continuation likely
if dealer_pressure > 50.0 and abs(net_gamma) > 100000000: # 100M threshold
return True
return False
def validate_flow_reversal(
self,
df: pd.DataFrame,
row_idx: int
) -> bool:
"""
Validate if flow reversal is likely.
Reversal happens when:
- Dealers finish hedging (gamma exposure neutralizes)
- Price fails at key levels (VWAP, opening range)
- Flow shows distribution pattern (decreasing premium, widening gaps)
"""
current_row = df.iloc[row_idx]
dealer_pressure = current_row.get('dealer_hedge_pressure_score', 0) or 0
follow_on_ratio = current_row.get('follow_on_ratio')
flow_acceleration = current_row.get('flow_acceleration')
# Low dealer pressure + low follow-on ratio = reversal likely
if dealer_pressure < 30.0:
if follow_on_ratio is not None and follow_on_ratio < 0.3:
return True
# Negative flow acceleration = flow weakening = reversal
if flow_acceleration is not None and flow_acceleration < -10000:
return True
return False
def enrich_with_dealer_context(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Add dealer-aware flow context metrics to DataFrame.
Adds: net_gamma_exposure_per_symbol, gamma_flip_proximity, dealer_hedge_pressure_score
"""
if df.empty:
return df
df = df.copy()
# Initialize columns
df['net_gamma_exposure_per_symbol'] = 0.0
df['gamma_flip_proximity'] = None
df['dealer_hedge_pressure_score'] = 0.0
# Sort by timestamp for proper gamma tracking
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
# Calculate metrics for each row
for idx in df_sorted.index:
original_idx = df_sorted.index[idx]
row = df_sorted.iloc[idx]
symbol = row.get('symbol_norm')
flow_ts_utc = row.get('flow_ts_utc')
if pd.notna(symbol) and pd.notna(flow_ts_utc):
# Net gamma exposure
net_gamma = self.calculate_net_gamma_exposure_per_symbol(
df_sorted, symbol, flow_ts_utc
)
df.at[original_idx, 'net_gamma_exposure_per_symbol'] = float(net_gamma)
# Gamma flip proximity
flip_prox = self.calculate_gamma_flip_proximity(df_sorted, idx)
if flip_prox is not None:
df.at[original_idx, 'gamma_flip_proximity'] = float(flip_prox)
# Dealer hedge pressure
pressure = self.calculate_dealer_hedge_pressure_score(df_sorted, idx)
df.at[original_idx, 'dealer_hedge_pressure_score'] = float(pressure)
logger.info(f"Dealer context enrichment complete. Mean pressure: {df['dealer_hedge_pressure_score'].mean():.2f}")
return df

View File

@ -0,0 +1,118 @@
"""
Flow Decay & Reversal Validation Service
Validates flow decay/reversal signals with anchors (premium, dealer pressure, price levels)
"""
import pandas as pd
import numpy as np
from typing import Optional
from enum import Enum
from utils.logger import logger
class FlowState(Enum):
"""Flow state classification"""
ACTIONABLE = "ACTIONABLE" # Flow decay/reversal is actionable (trade signal)
INFORMATIONAL = "INFORMATIONAL" # Flow decay/reversal is informational only (no trade signal)
class FlowDecayValidator:
"""
Validates flow decay and reversal signals.
Flow decay/reversal is actionable ONLY IF:
- Premium contracts (high relative premium)
- Dealer hedge pressure decreases
- Price fails near VWAP / opening range / key level
Otherwise mark as INFORMATIONAL.
"""
def __init__(self):
# Configuration
self.min_relative_premium_for_actionable = 60.0 # Minimum relative premium score
self.vwap_failure_threshold_pct = 0.5 # Price within 0.5% of VWAP = failure
self.dealer_pressure_decrease_threshold = 20.0 # Dealer pressure decrease threshold
def validate_flow_decay_reversal(
self,
df: pd.DataFrame,
row_idx: int
) -> str:
"""
Validate if flow decay/reversal is actionable.
Returns FlowState enum value as string.
"""
if row_idx >= len(df):
return FlowState.INFORMATIONAL.value
current_row = df.iloc[row_idx]
# Check 1: Premium contracts (relative premium score)
relative_premium_score = current_row.get('relative_premium_score', 0) or 0
if relative_premium_score < self.min_relative_premium_for_actionable:
return FlowState.INFORMATIONAL.value
# Check 2: Dealer hedge pressure decreases
# Look at recent dealer pressure trend
symbol = current_row.get('symbol_norm')
flow_ts_utc = current_row.get('flow_ts_utc')
if pd.notna(symbol) and pd.notna(flow_ts_utc):
from datetime import timedelta
window_start = flow_ts_utc - timedelta(minutes=30)
mask = (
(df['symbol_norm'] == symbol.upper()) &
(df['flow_ts_utc'] >= window_start) &
(df['flow_ts_utc'] <= flow_ts_utc) &
(df['dealer_hedge_pressure_score'].notna())
)
recent_pressure = df[mask]['dealer_hedge_pressure_score']
if len(recent_pressure) >= 2:
current_pressure = current_row.get('dealer_hedge_pressure_score', 0) or 0
recent_avg = recent_pressure.iloc[:-1].mean()
pressure_decrease = recent_avg - current_pressure
if pressure_decrease < self.dealer_pressure_decrease_threshold:
return FlowState.INFORMATIONAL.value
# Check 3: Price fails near VWAP / opening range / key level
price_vs_vwap_pct = current_row.get('price_vs_vwap_pct')
pct_vs_rth_open = current_row.get('pct_vs_rth_open')
price_failure = False
# VWAP failure
if price_vs_vwap_pct is not None and not pd.isna(price_vs_vwap_pct):
if abs(price_vs_vwap_pct) <= self.vwap_failure_threshold_pct:
price_failure = True
# Opening range failure
if pct_vs_rth_open is not None and not pd.isna(pct_vs_rth_open):
if abs(pct_vs_rth_open) <= 0.3: # Within 0.3% of open
price_failure = True
if not price_failure:
return FlowState.INFORMATIONAL.value
# All checks passed: actionable
return FlowState.ACTIONABLE.value
def enrich_with_flow_state(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Add flow state validation to DataFrame.
Adds: flow_state
"""
if df.empty:
return df
df = df.copy()
df['flow_state'] = FlowState.ACTIONABLE.value # Default to actionable
# Only validate flow decay/reversal cases
# For now, mark all as actionable (can be enhanced based on flow_acceleration, follow_on_ratio)
# This is a placeholder - in production, you'd identify decay/reversal patterns first
logger.info(f"Flow state validation complete. Actionable: {(df['flow_state'] == FlowState.ACTIONABLE.value).sum()}")
return df

View File

@ -0,0 +1,189 @@
"""
Institutional Confidence Metrics Service
Calculates confidence scores for institutional flow signals
"""
import pandas as pd
import numpy as np
from typing import Optional
from utils.logger import logger
class InstitutionalConfidence:
"""
Calculates institutional confidence metrics:
- confidence_score (0-100)
- institutional_likelihood (0-1)
- dealer_pain_level (0-100)
- expected_move_vs_implied (ratio)
"""
def __init__(self):
# Configuration
self.min_premium_for_institutional = 200000 # Minimum premium for institutional classification
def calculate_confidence_score(self, row: pd.Series) -> float:
"""
Calculate overall confidence score (0-100) combining multiple factors.
Higher = more confidence in signal quality.
"""
score = 0.0
# Relative premium component (0-25 points)
relative_premium = row.get('relative_premium_score', 0) or 0
score += (relative_premium / 100.0) * 25.0
# Signal strength component (0-25 points)
signal_strength = row.get('signal_strength', 0) or 0
score += (signal_strength / 100.0) * 25.0
# Dealer pressure component (0-20 points)
dealer_pressure = row.get('dealer_hedge_pressure_score', 0) or 0
score += (dealer_pressure / 100.0) * 20.0
# Flow continuation component (0-15 points)
follow_on_ratio = row.get('follow_on_ratio')
if follow_on_ratio is not None and not pd.isna(follow_on_ratio):
score += follow_on_ratio * 15.0
# Strike laddering component (0-15 points)
strike_laddering = row.get('strike_laddering_detected', False)
if strike_laddering:
score += 15.0
return min(100.0, max(0.0, score))
def calculate_institutional_likelihood(self, row: pd.Series) -> float:
"""
Calculate likelihood that flow is institutional (0-1).
Based on premium size, trade characteristics, and patterns.
"""
premium_num = row.get('premium_num', 0) or 0
relative_premium = row.get('relative_premium_score', 0) or 0
size_concentration = row.get('size_concentration_score', 0) or 0
likelihood = 0.0
# Premium size component (0-40%)
if premium_num >= 1000000: # $1M+
likelihood += 0.40
elif premium_num >= 500000: # $500K+
likelihood += 0.30
elif premium_num >= self.min_premium_for_institutional:
likelihood += 0.20
# Relative premium component (0-30%)
if relative_premium >= 80:
likelihood += 0.30
elif relative_premium >= 60:
likelihood += 0.20
elif relative_premium >= 40:
likelihood += 0.10
# Size concentration component (0-30%)
# Institutional trades are often concentrated
if size_concentration >= 70:
likelihood += 0.30
elif size_concentration >= 50:
likelihood += 0.20
elif size_concentration >= 30:
likelihood += 0.10
return min(1.0, max(0.0, likelihood))
def calculate_dealer_pain_level(self, row: pd.Series) -> float:
"""
Calculate dealer pain level (0-100).
Higher = dealers in pain (large gamma exposure, forced to hedge).
"""
dealer_pressure = row.get('dealer_hedge_pressure_score', 0) or 0
net_gamma = abs(row.get('net_gamma_exposure_per_symbol', 0) or 0)
gamma_flip_prox = row.get('gamma_flip_proximity')
pain = 0.0
# Dealer pressure component (0-50 points)
pain += (dealer_pressure / 100.0) * 50.0
# Gamma exposure component (0-30 points)
# Large absolute gamma = more pain
if net_gamma > 0:
normalized_gamma = min(1.0, net_gamma / 1000000000) # 1B threshold
pain += normalized_gamma * 30.0
# Gamma flip proximity component (0-20 points)
# Near flip = high pain (dealers forced to adjust)
if gamma_flip_prox is not None and not pd.isna(gamma_flip_prox):
# Absolute value of proximity (closer to 0 = more pain)
pain += (1.0 - abs(gamma_flip_prox)) * 20.0
return min(100.0, max(0.0, pain))
def calculate_expected_move_vs_implied(self, row: pd.Series) -> Optional[float]:
"""
Calculate expected move vs implied move ratio.
Estimates expected move from flow characteristics vs implied volatility.
Returns: ratio (expected_move / implied_move)
- >1.0 = flow suggests larger move than implied
- <1.0 = flow suggests smaller move than implied
- None if cannot calculate
"""
# Simplified calculation: use premium and delta exposure as proxies
premium_num = row.get('premium_num', 0) or 0
spot_num = row.get('spot_num', 0) or 0
delta_exposure = abs(row.get('delta_exposure', 0) or 0)
if pd.isna(spot_num) or spot_num == 0:
return None
# Estimate expected move from premium paid
# High premium relative to spot = expectation of larger move
if premium_num > 0:
prem_to_spot_ratio = premium_num / spot_num
# Estimate implied move (simplified: assume 1% IV = 1% move expectation)
# This is a placeholder - in production, use actual IV from options chain
implied_move_pct = 2.0 # Default 2% implied move
# Estimate expected move from premium
# Premium of 2% of spot = expectation of ~2% move (rough approximation)
expected_move_pct = prem_to_spot_ratio * 100.0
# Calculate ratio
if implied_move_pct > 0:
ratio = expected_move_pct / implied_move_pct
return float(ratio)
return None
def enrich_with_confidence_metrics(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Add institutional confidence metrics to DataFrame.
Adds: confidence_score, institutional_likelihood, dealer_pain_level, expected_move_vs_implied
"""
if df.empty:
return df
df = df.copy()
# Initialize columns
df['confidence_score'] = 0.0
df['institutional_likelihood'] = 0.0
df['dealer_pain_level'] = 0.0
df['expected_move_vs_implied'] = None
# Calculate metrics
df['confidence_score'] = df.apply(self.calculate_confidence_score, axis=1)
df['institutional_likelihood'] = df.apply(self.calculate_institutional_likelihood, axis=1)
df['dealer_pain_level'] = df.apply(self.calculate_dealer_pain_level, axis=1)
# Expected move vs implied (some rows may not have this)
for idx in df.index:
expected_move = self.calculate_expected_move_vs_implied(df.iloc[idx])
if expected_move is not None:
df.at[idx, 'expected_move_vs_implied'] = float(expected_move)
logger.info(f"Confidence metrics complete. Mean confidence: {df['confidence_score'].mean():.2f}")
return df

View File

@ -0,0 +1,232 @@
"""
Intent Classification Service
Replaces naive direction (BULL/BEAR) with nuanced volatility and hedging intent classification
"""
import pandas as pd
import numpy as np
from typing import Optional
from enum import Enum
from utils.logger import logger
from utils.error_handler import safe_divide
class VolatilityIntent(Enum):
"""Volatility and hedging intent classification"""
LONG_VOL = "LONG_VOL" # Buying volatility (call/put buying, expecting large moves)
SHORT_VOL = "SHORT_VOL" # Selling volatility (premium collection, expecting low vol)
DIRECTIONAL = "DIRECTIONAL" # Directional positioning (directional bias)
HEDGE_UNWIND = "HEDGE_UNWIND" # Hedging or unwinding existing positions
class IntentClassifier:
"""
Classifies options flow intent beyond simple BULL/BEAR direction.
Identifies volatility trading, hedging, and directional positioning.
"""
def __init__(self):
# Configuration thresholds
self.otm_threshold_pct = 5.0 # Strikes >5% OTM considered OTM
self.long_vol_premium_threshold = 100000 # Minimum premium for long vol signal
self.short_vol_premium_threshold = 200000 # Minimum premium for short vol signal
def estimate_delta(self, row: pd.Series) -> float:
"""
Estimate option delta from moneyness.
Returns delta estimate (0-1 for calls, -1-0 for puts, use absolute value).
"""
spot_num = row.get('spot_num', 0) or 0
strike_num = row.get('strike_num', 0) or 0
cp_norm = row.get('cp_norm', '')
if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num) or pd.isna(cp_norm):
return 0.5 # Default to ATM
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
if cp_norm == 'CALL':
if strike_num <= spot_num:
# ITM call: delta 0.5 to 1.0
delta = 0.5 + (1.0 - moneyness_ratio) * 0.5
else:
# OTM call: delta 0.5 to 0.0
delta = max(0.0, 0.5 * (1.5 - moneyness_ratio) / 0.5)
else: # PUT
if strike_num >= spot_num:
# ITM put: delta -0.5 to -1.0 (return absolute)
delta = abs(0.5 + (moneyness_ratio - 1.0) * 0.5)
else:
# OTM put: delta -0.5 to 0.0 (return absolute)
delta = max(0.0, 0.5 * (1.0 - moneyness_ratio) / 0.5)
return float(delta)
def estimate_gamma(self, row: pd.Series, delta: float) -> float:
"""
Estimate option gamma (sensitivity of delta to price changes).
Higher near ATM, lower ITM/OTM.
Returns gamma estimate (positive value).
"""
spot_num = row.get('spot_num', 0) or 0
strike_num = row.get('strike_num', 0) or 0
if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num):
return 0.0
# Gamma is highest at-the-money
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
# Simple approximation: gamma peaks at 1.0 (ATM) and decays away
# Use normal distribution approximation
distance_from_atm = abs(moneyness_ratio - 1.0)
# Gamma ≈ exp(-distance^2 / (2*sigma^2)) where sigma ≈ 0.1 (10% moneyness)
gamma = np.exp(-(distance_from_atm ** 2) / (2 * 0.01))
return float(gamma)
def calculate_delta_exposure(self, row: pd.Series) -> float:
"""
Calculate delta exposure: contracts * delta * 100 * spot_price.
Positive = long delta (bullish), Negative = short delta (bearish).
"""
premium_num = row.get('premium_num', 0) or 0
spot_num = row.get('spot_num', 0) or 0
vol_num = row.get('vol_num', 0) or 0
cp_norm = row.get('cp_norm', '')
side_norm = row.get('side_norm', '')
if pd.isna(spot_num) or spot_num == 0:
return 0.0
# Estimate delta
delta = self.estimate_delta(row)
# Determine sign based on call/put and buy/sell
if cp_norm == 'CALL':
if side_norm == 'BUY':
delta_sign = 1.0 # Long calls = positive delta
else: # SELL
delta_sign = -1.0 # Short calls = negative delta
else: # PUT
if side_norm == 'BUY':
delta_sign = -1.0 # Long puts = negative delta
else: # SELL
delta_sign = 1.0 # Short puts = positive delta
# Delta exposure = contracts * delta * 100 * spot
# Use volume as proxy for contracts
contracts = vol_num if not pd.isna(vol_num) else 0
delta_exposure = contracts * delta * delta_sign * 100 * spot_num
return float(delta_exposure)
def calculate_gamma_exposure(self, row: pd.Series) -> float:
"""
Calculate gamma exposure: contracts * gamma * 100 * spot_price^2.
Positive = long gamma (volatility long), Negative = short gamma (volatility short).
"""
premium_num = row.get('premium_num', 0) or 0
spot_num = row.get('spot_num', 0) or 0
vol_num = row.get('vol_num', 0) or 0
cp_norm = row.get('cp_norm', '')
side_norm = row.get('side_norm', '')
if pd.isna(spot_num) or spot_num == 0:
return 0.0
# Estimate delta and gamma
delta = self.estimate_delta(row)
gamma = self.estimate_gamma(row, delta)
# Determine sign: buying options = long gamma, selling = short gamma
if side_norm == 'BUY':
gamma_sign = 1.0 # Long gamma
else: # SELL
gamma_sign = -1.0 # Short gamma
# Gamma exposure = contracts * gamma * 100 * spot^2
contracts = vol_num if not pd.isna(vol_num) else 0
gamma_exposure = contracts * gamma * gamma_sign * 100 * (spot_num ** 2)
return float(gamma_exposure)
def classify_volatility_intent(self, row: pd.Series) -> str:
"""
Classify volatility intent based on trade characteristics.
Returns VolatilityIntent enum value as string.
"""
premium_num = row.get('premium_num', 0) or 0
spot_num = row.get('spot_num', 0) or 0
strike_num = row.get('strike_num', 0) or 0
cp_norm = row.get('cp_norm', '')
side_norm = row.get('side_norm', '')
vol_num = row.get('vol_num', 0) or 0
oi_num = row.get('oi_num', 0) or 0
# Calculate moneyness
if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num):
mny_pct = 0.0
else:
if cp_norm == 'CALL':
mny_pct = ((strike_num - spot_num) / spot_num * 100.0) if spot_num > 0 else 0
else: # PUT
mny_pct = ((spot_num - strike_num) / spot_num * 100.0) if spot_num > 0 else 0
is_otm = abs(mny_pct) > self.otm_threshold_pct
# Long volatility: buying OTM options (calls or puts)
# High premium, OTM strikes, buying side
if (side_norm == 'BUY' and
is_otm and
premium_num >= self.long_vol_premium_threshold):
return VolatilityIntent.LONG_VOL.value
# Short volatility: selling options, collecting premium
# High premium, selling side, vol > OI (opening new short positions)
if (side_norm == 'SELL' and
premium_num >= self.short_vol_premium_threshold and
vol_num > oi_num):
return VolatilityIntent.SHORT_VOL.value
# Directional: ITM options, buying side, strong directional flow
if (side_norm == 'BUY' and
not is_otm and
premium_num >= 50000):
return VolatilityIntent.DIRECTIONAL.value
# Hedge/Unwind: Selling existing positions (vol < OI)
# Or buying protective puts/calls
if (side_norm == 'SELL' and vol_num < oi_num) or \
(side_norm == 'BUY' and is_otm and premium_num < self.long_vol_premium_threshold):
return VolatilityIntent.HEDGE_UNWIND.value
# Default: directional (fallback)
return VolatilityIntent.DIRECTIONAL.value
def enrich_with_intent_classification(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Add intent classification metrics to DataFrame.
Adds: delta_exposure, gamma_exposure, volatility_intent
"""
if df.empty:
return df
df = df.copy()
# Initialize columns
df['delta_exposure'] = 0.0
df['gamma_exposure'] = 0.0
df['volatility_intent'] = VolatilityIntent.DIRECTIONAL.value
# Calculate metrics
df['delta_exposure'] = df.apply(self.calculate_delta_exposure, axis=1)
df['gamma_exposure'] = df.apply(self.calculate_gamma_exposure, axis=1)
df['volatility_intent'] = df.apply(self.classify_volatility_intent, axis=1)
# Log distribution
intent_counts = df['volatility_intent'].value_counts()
logger.info(f"Intent classification complete. Distribution: {intent_counts.to_dict()}")
return df

View File

@ -0,0 +1,104 @@
"""
Market Regime Detection Service
Identifies market regime to gate trade signal generation
"""
import pandas as pd
import numpy as np
from typing import Optional
from enum import Enum
from utils.logger import logger
class MarketRegime(Enum):
"""Market regime classification"""
TREND = "TREND" # Trending market (continuation bias)
RANGE = "RANGE" # Range-bound market (fade or vol-sell bias)
HIGH_VOL_EVENT = "HIGH_VOL_EVENT" # High volatility event (volatility expansion bias)
class MarketRegimeDetector:
"""
Detects market regime to inform trade signal bias:
- Trend: continuation trades preferred
- Range: mean reversion / vol selling preferred
- High Vol Event: volatility expansion trades preferred
"""
def __init__(self):
# Configuration
self.trend_threshold_pct = 1.5 # >1.5% move = trending
self.range_threshold_pct = 0.5 # <0.5% move = ranging
self.high_vol_threshold_pct = 3.0 # >3% move = high vol event
self.lookback_minutes = 60 # Lookback window for regime detection
def detect_regime(
self,
df: pd.DataFrame,
row_idx: int
) -> str:
"""
Detect market regime for a given flow event.
Returns MarketRegime enum value as string.
"""
if row_idx >= len(df):
return MarketRegime.RANGE.value
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
flow_ts_utc = current_row.get('flow_ts_utc')
if pd.isna(symbol) or pd.isna(flow_ts_utc):
return MarketRegime.RANGE.value
# Get price movement over lookback window
pct_vs_rth_open = current_row.get('pct_vs_rth_open')
pct_vs_prior_close = current_row.get('pct_vs_prior_close')
pct_15m_momo = current_row.get('pct_15m_momo')
# Use most appropriate price metric
price_move_pct = None
if pct_vs_rth_open is not None and not pd.isna(pct_vs_rth_open):
price_move_pct = abs(pct_vs_rth_open)
elif pct_vs_prior_close is not None and not pd.isna(pct_vs_prior_close):
price_move_pct = abs(pct_vs_prior_close)
elif pct_15m_momo is not None and not pd.isna(pct_15m_momo):
price_move_pct = abs(pct_15m_momo)
if price_move_pct is None:
return MarketRegime.RANGE.value # Default to range
# Classify regime
if price_move_pct >= self.high_vol_threshold_pct:
return MarketRegime.HIGH_VOL_EVENT.value
elif price_move_pct >= self.trend_threshold_pct:
return MarketRegime.TREND.value
elif price_move_pct <= self.range_threshold_pct:
return MarketRegime.RANGE.value
else:
# Between range and trend threshold - classify based on momentum
if pct_15m_momo is not None and not pd.isna(pct_15m_momo):
if abs(pct_15m_momo) > 0.75:
return MarketRegime.TREND.value
return MarketRegime.RANGE.value
def enrich_with_market_regime(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Add market regime classification to DataFrame.
Adds: market_regime
"""
if df.empty:
return df
df = df.copy()
df['market_regime'] = MarketRegime.RANGE.value
# Detect regime for each row
for idx in df.index:
regime = self.detect_regime(df, idx)
df.at[idx, 'market_regime'] = regime
regime_counts = df['market_regime'].value_counts()
logger.info(f"Market regime detection complete. Distribution: {regime_counts.to_dict()}")
return df

View File

@ -0,0 +1,213 @@
"""
Tier-0 Noise Rejection Service
Filters out low-quality signals before enrichment to reduce processing overhead
"""
import pandas as pd
import numpy as np
from typing import Optional
from datetime import timedelta
from utils.logger import logger
from utils.error_handler import safe_divide
class NoiseRejector:
"""
Rejects early-stage noise before enrichment to optimize processing.
Rejects if:
- Single isolated trade (no repeat activity)
- Far OTM weekly lottos
- Delta-adjusted premium below threshold
"""
def __init__(self):
# Configuration
self.repeat_activity_window_minutes = 30 # Minutes to look for repeat activity
self.min_delta_adjusted_premium = 50000 # Minimum delta-adjusted premium
self.max_otm_percentage = 15.0 # Reject strikes >15% OTM
self.min_expiry_days = 1 # Minimum days to expiry (reject same-day/weekly lottos)
def is_isolated_trade(
self,
df: pd.DataFrame,
row_idx: int
) -> bool:
"""
Check if trade is isolated (no repeat activity within window).
Returns True if isolated (should reject).
"""
if row_idx >= len(df):
return True
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
direction = current_row.get('direction')
flow_ts_utc = current_row.get('flow_ts_utc')
if pd.isna(symbol) or pd.isna(flow_ts_utc):
return True
# Look for trades in same direction within window
window_start = flow_ts_utc - timedelta(minutes=self.repeat_activity_window_minutes)
same_symbol = df['symbol_norm'] == symbol
same_direction = df['direction'] == direction
in_window = (df['flow_ts_utc'] >= window_start) & (df['flow_ts_utc'] < flow_ts_utc)
# Exclude current row
other_trades = df[same_symbol & same_direction & in_window]
# If no other trades in window, it's isolated
return len(other_trades) == 0
def is_far_otm_lotto(
self,
row: pd.Series
) -> bool:
"""
Check if trade is a far OTM weekly lotto (low probability, low signal value).
Returns True if should reject.
"""
spot_num = row.get('spot_num', 0) or 0
strike_num = row.get('strike_num', 0) or 0
cp_norm = row.get('cp_norm', '')
exp_date = row.get('exp_date')
flow_date = row.get('flow_date_cst')
if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num) or pd.isna(cp_norm):
return False
# Calculate moneyness percentage
if cp_norm == 'CALL':
otm_pct = ((strike_num - spot_num) / spot_num * 100.0) if spot_num > 0 else 0
else: # PUT
otm_pct = ((spot_num - strike_num) / spot_num * 100.0) if spot_num > 0 else 0
# Reject if >15% OTM
if otm_pct > self.max_otm_percentage:
return True
# Check if weekly lotto (expiry within 7 days)
if pd.notna(exp_date) and pd.notna(flow_date):
try:
if isinstance(exp_date, str):
from datetime import datetime
exp_date = datetime.strptime(exp_date, '%Y-%m-%d').date()
if isinstance(flow_date, str):
from datetime import datetime
flow_date = datetime.strptime(flow_date, '%Y-%m-%d').date()
days_to_expiry = (exp_date - flow_date).days
# Reject weekly lottos that are also far OTM
if days_to_expiry <= 7 and otm_pct > 10.0:
return True
except (ValueError, TypeError, AttributeError):
pass
return False
def calculate_delta_adjusted_premium(
self,
row: pd.Series
) -> float:
"""
Calculate delta-adjusted premium (premium * |delta|).
Approximates intrinsic value component of premium.
For simplicity, we estimate delta from moneyness:
- ATM: delta 0.5
- ITM: delta increases toward 1.0
- OTM: delta decreases toward 0.0
"""
premium_num = row.get('premium_num', 0) or 0
spot_num = row.get('spot_num', 0) or 0
strike_num = row.get('strike_num', 0) or 0
cp_norm = row.get('cp_norm', '')
if pd.isna(premium_num) or premium_num == 0 or pd.isna(spot_num) or spot_num == 0:
return 0.0
# Estimate delta from moneyness
if cp_norm == 'CALL':
if strike_num <= spot_num:
# ITM call: delta from 0.5 to 1.0
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
# Strike at spot = 0.5, strike at 0 = 1.0
estimated_delta = 0.5 + (1.0 - moneyness_ratio) * 0.5
else:
# OTM call: delta from 0.5 to 0.0
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
# Strike at spot = 0.5, strike at 1.15x spot = 0.0
estimated_delta = max(0.0, 0.5 * (1.5 - moneyness_ratio) / 0.5)
else: # PUT
if strike_num >= spot_num:
# ITM put: delta from -0.5 to -1.0 (use absolute value)
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
estimated_delta = 0.5 + (moneyness_ratio - 1.0) * 0.5
else:
# OTM put: delta from 0.5 to 0.0 (use absolute value)
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
estimated_delta = max(0.0, 0.5 * (1.0 - moneyness_ratio) / 0.5)
# Delta-adjusted premium
delta_adj_premium = premium_num * abs(estimated_delta)
return float(delta_adj_premium)
def should_reject(
self,
df: pd.DataFrame,
row_idx: int
) -> bool:
"""
Determine if row should be rejected as noise.
Returns True if should reject (mark early_noise_reject = True).
"""
if row_idx >= len(df):
return True
row = df.iloc[row_idx]
# Reject isolated trades
if self.is_isolated_trade(df, row_idx):
return True
# Reject far OTM lottos
if self.is_far_otm_lotto(row):
return True
# Reject if delta-adjusted premium below threshold
delta_adj_premium = self.calculate_delta_adjusted_premium(row)
if delta_adj_premium < self.min_delta_adjusted_premium:
return True
return False
def mark_noise_rejections(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Mark rows for early noise rejection.
Adds: early_noise_reject (boolean)
"""
if df.empty:
return df
df = df.copy()
# Initialize column
df['early_noise_reject'] = False
# Sort by timestamp for proper isolation detection
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
# Mark rejections
for idx in df_sorted.index:
if self.should_reject(df_sorted, idx):
original_idx = df_sorted.index[idx]
df.at[original_idx, 'early_noise_reject'] = True
rejection_count = df['early_noise_reject'].sum()
logger.info(f"Noise rejection: {rejection_count}/{len(df)} rows marked for rejection ({rejection_count/len(df)*100:.1f}%)")
return df

View File

@ -146,5 +146,14 @@ class OutputFormatter:
# Ensure Phase 1 columns are preserved (don't drop them) # Ensure Phase 1 columns are preserved (don't drop them)
# Phase 1 columns should remain as-is (signal_tier, checklist_score, etc.) # Phase 1 columns should remain as-is (signal_tier, checklist_score, etc.)
# Ensure all new institutional analytics columns are preserved:
# - premium_zscore, premium_percentile_intraday, relative_premium_score
# - aggression_score, size_concentration_score, repeat_trade_velocity, strike_clustering_score, signal_strength
# - early_noise_reject, flow_acceleration, time_between_hits, follow_on_ratio, strike_laddering_detected
# - delta_exposure, gamma_exposure, volatility_intent
# - net_gamma_exposure_per_symbol, gamma_flip_proximity, dealer_hedge_pressure_score
# - market_regime, flow_state
# - confidence_score, institutional_likelihood, dealer_pain_level, expected_move_vs_implied
return df return df

View File

@ -0,0 +1,258 @@
"""
Relative Premium Scoring Service
Replaces static premium filter with context-aware relative premium scoring
"""
import pandas as pd
import numpy as np
from typing import Dict, Optional
from datetime import datetime, timedelta
import asyncpg
from utils.logger import logger
from utils.error_handler import safe_divide
class RelativePremiumScorer:
"""
Computes relative premium metrics to replace static premium filtering.
Premium of $80K might be significant for AAPL but noise for TSLA.
"""
def __init__(self, pool: asyncpg.Pool = None):
self.pool = pool
# Configuration
self.rolling_window_days = 20 # Rolling window for z-score calculation
self.min_relative_premium_threshold = 0.60 # 60th percentile minimum
async def fetch_historical_premium_stats(
self,
symbol: str,
reference_date: datetime.date
) -> Dict[str, float]:
"""
Fetch historical premium statistics for a symbol.
Returns: {mean, std, min, max, median} for rolling window
"""
if not self.pool:
logger.warning("No database pool available for historical premium stats")
return {}
try:
# Calculate window start date
window_start = reference_date - timedelta(days=self.rolling_window_days + 5) # Extra buffer
async with self.pool.acquire() as conn:
query = """
SELECT
AVG(premium_num) as mean_premium,
STDDEV(premium_num) as std_premium,
MIN(premium_num) as min_premium,
MAX(premium_num) as max_premium,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY premium_num) as median_premium
FROM (
SELECT
CAST(REGEXP_REPLACE("Premium"::text, '[^\d.]', '', 'g') AS numeric) as premium_num
FROM "OptionsFlow_monthly"
WHERE UPPER(TRIM("Symbol")) = $1
AND "Premium" IS NOT NULL
AND TRIM("Premium"::text) <> ''
AND "CreatedDate" >= $2
AND "CreatedDate" <= $3
AND "StockEtf" = 'STOCK'
) subq
WHERE premium_num > 0
"""
row = await conn.fetchrow(
query,
symbol.upper(),
window_start.strftime('%Y-%m-%d'),
reference_date.strftime('%Y-%m-%d')
)
if row and row['mean_premium']:
return {
'mean': float(row['mean_premium']) if row['mean_premium'] else 0.0,
'std': float(row['std_premium']) if row['std_premium'] else 0.0,
'min': float(row['min_premium']) if row['min_premium'] else 0.0,
'max': float(row['max_premium']) if row['max_premium'] else 0.0,
'median': float(row['median_premium']) if row['median_premium'] else 0.0,
}
except Exception as e:
logger.debug(f"Error fetching historical premium stats for {symbol}: {e}")
return {}
async def calculate_intraday_percentile(
self,
df: pd.DataFrame,
symbol: str,
flow_date: datetime.date,
premium: float
) -> Optional[float]:
"""
Calculate percentile rank of premium within same-day flow for the symbol.
Returns 0-100 percentile value.
"""
try:
# Filter to same symbol and date
same_day_flow = df[
(df['symbol_norm'] == symbol.upper()) &
(df['flow_date_cst'] == flow_date) &
(df['premium_num'].notna()) &
(df['premium_num'] > 0)
]
if len(same_day_flow) < 3: # Need at least 3 trades for meaningful percentile
return None
# Calculate percentile rank
all_premiums = same_day_flow['premium_num'].values
percentile = (np.sum(all_premiums <= premium) / len(all_premiums)) * 100.0
return float(percentile)
except Exception as e:
logger.debug(f"Error calculating intraday percentile for {symbol}: {e}")
return None
def calculate_zscore(
self,
premium: float,
stats: Dict[str, float]
) -> Optional[float]:
"""
Calculate z-score of premium relative to historical distribution.
Returns z-score (standard deviations from mean).
"""
if not stats or stats.get('std', 0) == 0:
return None
mean = stats.get('mean', 0)
std = stats.get('std', 1)
if std == 0:
return None
zscore = (premium - mean) / std
return float(zscore)
def calculate_relative_premium_score(
self,
premium: float,
zscore: Optional[float],
intraday_percentile: Optional[float],
stats: Dict[str, float]
) -> float:
"""
Calculate composite relative premium score (0-100).
Combines z-score and intraday percentile with median normalization.
Logic:
- High z-score (unusual size) = higher score
- High intraday percentile (large relative to today's flow) = higher score
- Normalize by median to account for symbol-specific scaling
"""
score = 0.0
# Z-score component (40% weight)
# Z-score > 1 = 1 std above mean, Z-score > 2 = 2 std above mean
if zscore is not None:
# Normalize z-score to 0-40 range (clamp at ±3 sigma)
zscore_normalized = max(0, min(40, (zscore + 3) * (40 / 6)))
score += zscore_normalized
# Intraday percentile component (40% weight)
if intraday_percentile is not None:
# Percentile is already 0-100, scale to 0-40
score += (intraday_percentile / 100.0) * 40.0
# Median normalization component (20% weight)
# Premium > median gets bonus, premium < median gets penalty
if stats and stats.get('median', 0) > 0:
median_ratio = premium / stats['median']
# Ratio > 1.5 = strong, ratio > 2.0 = very strong
if median_ratio >= 2.0:
score += 20.0
elif median_ratio >= 1.5:
score += 15.0
elif median_ratio >= 1.0:
score += 10.0
elif median_ratio >= 0.5:
score += 5.0
return min(100.0, max(0.0, score))
async def enrich_with_relative_premium(
self,
df: pd.DataFrame
) -> pd.DataFrame:
"""
Add relative premium metrics to DataFrame.
Adds: premium_zscore, premium_percentile_intraday, relative_premium_score
"""
if df.empty:
return df
df = df.copy()
# Initialize new columns
df['premium_zscore'] = None
df['premium_percentile_intraday'] = None
df['relative_premium_score'] = 0.0
# Cache historical stats per symbol to avoid repeated queries
stats_cache: Dict[str, Dict[str, float]] = {}
# Group by symbol and date for batch processing
unique_symbols = df['symbol_norm'].unique()
unique_dates = df['flow_date_cst'].unique()
logger.info(f"Calculating relative premium scores for {len(unique_symbols)} symbols")
for symbol in unique_symbols:
# Fetch historical stats once per symbol
if self.pool:
# Use the most recent date for this symbol as reference
symbol_dates = df[df['symbol_norm'] == symbol]['flow_date_cst'].unique()
if len(symbol_dates) > 0:
reference_date = max(symbol_dates)
stats = await self.fetch_historical_premium_stats(symbol, reference_date)
stats_cache[symbol] = stats
else:
stats_cache[symbol] = {}
else:
stats_cache[symbol] = {}
# Calculate metrics for each row
for idx, row in df.iterrows():
premium = row.get('premium_num')
if pd.isna(premium) or premium <= 0:
continue
symbol = row['symbol_norm']
flow_date = row.get('flow_date_cst')
# Get historical stats
stats = stats_cache.get(symbol, {})
# Calculate z-score
zscore = self.calculate_zscore(premium, stats)
if zscore is not None:
df.at[idx, 'premium_zscore'] = float(zscore)
# Calculate intraday percentile (using current DataFrame subset)
intraday_percentile = self.calculate_intraday_percentile(
df, symbol, flow_date, premium
)
if intraday_percentile is not None:
df.at[idx, 'premium_percentile_intraday'] = float(intraday_percentile)
# Calculate composite relative premium score
relative_score = self.calculate_relative_premium_score(
premium, zscore, intraday_percentile, stats
)
df.at[idx, 'relative_premium_score'] = float(relative_score)
logger.info(f"Relative premium scoring complete. Mean score: {df['relative_premium_score'].mean():.2f}")
return df

View File

@ -0,0 +1,331 @@
"""
Signal Component Scorer
Converts binary badge logic into continuous numeric signal components
"""
import pandas as pd
import numpy as np
from typing import Dict, Optional
from utils.logger import logger
class SignalComponentScorer:
"""
Replaces binary badge logic (💎 🟢 🔴) with continuous numeric scores.
Badges remain display-only, but signal_strength is computed from components.
"""
def __init__(self):
# Signal strength weights (must sum to 1.0)
self.aggression_weight = 0.30
self.size_concentration_weight = 0.30
self.repeat_velocity_weight = 0.20
self.strike_clustering_weight = 0.20
def calculate_aggression_score(self, row: pd.Series) -> float:
"""
Measures trade aggression: premium paid at ask vs bid, ITM vs OTM preference.
Higher score = more aggressive buying:
- ITM premiums indicate willingness to pay intrinsic value
- Ask-side trades (AA) indicate market orders
- Large premium relative to spot price
"""
score = 0.0
premium_num = row.get('premium_num', 0) or 0
spot_num = row.get('spot_num', 0) or 0
cp_norm = row.get('cp_norm', '')
side = str(row.get('Side', '')).upper()
# ITM premium component (0-40 points)
bull_prem_itm = row.get('bull_prem_itm', 0) or 0
bear_prem_itm = row.get('bear_prem_itm', 0) or 0
total_prem_itm = bull_prem_itm + bear_prem_itm
bull_total = row.get('bull_total', 0) or 0
bear_total = row.get('bear_total', 0) or 0
total_premium = bull_total + bear_total
if total_premium > 0:
itm_ratio = total_prem_itm / total_premium
score += itm_ratio * 40.0
# Ask-side aggression component (0-30 points)
# AA = buying at ask (market orders, urgency)
has_aa = 'AA' in side or 'AT_ASK' in side
has_bb = 'BB' in side or 'AT_BID' in side
if has_aa and not has_bb:
score += 30.0
elif has_aa:
score += 15.0
# Premium-to-spot ratio component (0-30 points)
# Large premium relative to spot indicates aggressive positioning
if spot_num > 0 and premium_num > 0:
prem_to_spot_ratio = premium_num / spot_num
# Normalize: 0.01 (1%) = 10 points, 0.05 (5%) = 30 points
ratio_score = min(30.0, prem_to_spot_ratio * 600.0)
score += ratio_score
return min(100.0, max(0.0, score))
def calculate_size_concentration_score(self, row: pd.Series) -> float:
"""
Measures size concentration: how much premium is concentrated in single strikes/expiries.
Higher score = more concentrated (single large trade vs many small ones):
- High premium in single trade
- OI buildup in specific strikes
- Directional consistency (all calls or all puts)
"""
score = 0.0
premium_num = row.get('premium_num', 0) or 0
bull_total = row.get('bull_total', 0) or 0
bear_total = row.get('bear_total', 0) or 0
total_premium = bull_total + bear_total
# Single-trade premium concentration (0-40 points)
# Premium of current trade relative to running total
if total_premium > 0:
concentration_ratio = premium_num / total_premium
# Single trade = 50%+ of total = very concentrated
if concentration_ratio >= 0.5:
score += 40.0
elif concentration_ratio >= 0.3:
score += 30.0
elif concentration_ratio >= 0.2:
score += 20.0
elif concentration_ratio >= 0.1:
score += 10.0
# OI concentration component (0-30 points)
# High OI in OTM strikes indicates concentrated positioning
oi_cb_otm = row.get('oi_cb_otm', 0) or 0
oi_pb_otm = row.get('oi_pb_otm', 0) or 0
oi_all = row.get('oi_all', 0) or 0
if oi_all > 0:
otm_oi_ratio = (oi_cb_otm + oi_pb_otm) / oi_all
score += otm_oi_ratio * 30.0
# Directional consistency (0-30 points)
# Pure directional flow (all bull or all bear) = more concentrated
if total_premium > 0:
direction_strength = abs(bull_total - bear_total) / total_premium
score += direction_strength * 30.0
return min(100.0, max(0.0, score))
def calculate_repeat_trade_velocity(self, df: pd.DataFrame, row_idx: int) -> float:
"""
Measures repeat trade velocity: frequency of trades in same direction/expiry.
Higher score = faster follow-on trades (urgency building):
- Time between consecutive trades decreasing
- Multiple trades in same direction
- Same expiry date (rolling accumulation)
"""
if row_idx >= len(df):
return 0.0
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
direction = current_row.get('direction')
exp_date = current_row.get('exp_date')
flow_ts_utc = current_row.get('flow_ts_utc')
if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc):
return 0.0
# Look back at recent trades for same symbol
# Sort by timestamp to find preceding trades
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
current_idx = df_sorted.index[df_sorted.index == row_idx]
if len(current_idx) == 0:
return 0.0
current_pos = current_idx[0]
# Look at last 10 trades for this symbol
symbol_mask = (df_sorted['symbol_norm'] == symbol) & \
(df_sorted.index < current_pos) & \
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
recent_trades = df_sorted[symbol_mask].tail(10)
if len(recent_trades) == 0:
return 0.0
score = 0.0
# Time compression component (0-40 points)
# Decreasing time gaps = urgency building
if len(recent_trades) >= 2:
time_gaps = []
prev_ts = None
for _, trade in recent_trades.iterrows():
ts = trade.get('flow_ts_utc')
if pd.notna(ts) and prev_ts:
gap_minutes = (ts - prev_ts).total_seconds() / 60.0
if gap_minutes > 0:
time_gaps.append(gap_minutes)
prev_ts = ts
if len(time_gaps) >= 2:
# Check if gaps are decreasing (accelerating)
recent_gaps = time_gaps[-2:]
if recent_gaps[1] < recent_gaps[0]:
compression_ratio = recent_gaps[1] / recent_gaps[0] if recent_gaps[0] > 0 else 0
# 50% compression = 20 points, 80% compression = 40 points
score += (1.0 - compression_ratio) * 40.0
# Directional consistency (0-35 points)
# Same direction trades = building position
same_direction = recent_trades[recent_trades['direction'] == direction]
if len(recent_trades) > 0:
consistency_ratio = len(same_direction) / len(recent_trades)
score += consistency_ratio * 35.0
# Expiry concentration (0-25 points)
# Same expiry = rolling accumulation
if pd.notna(exp_date):
same_expiry = recent_trades[recent_trades['exp_date'] == exp_date]
if len(recent_trades) > 0:
expiry_ratio = len(same_expiry) / len(recent_trades)
score += expiry_ratio * 25.0
return min(100.0, max(0.0, score))
def calculate_strike_clustering_score(self, df: pd.DataFrame, row_idx: int) -> float:
"""
Measures strike clustering: trades accumulating at specific strike levels.
Higher score = more clustering (laddering or concentrated strikes):
- Multiple trades at same strike
- Sequential strikes (laddering)
- Strikes near current price (pin risk)
"""
if row_idx >= len(df):
return 0.0
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
strike_num = current_row.get('strike_num')
spot_num = current_row.get('spot_num', 0) or 0
flow_ts_utc = current_row.get('flow_ts_utc')
exp_date = current_row.get('exp_date')
if pd.isna(symbol) or pd.isna(strike_num) or pd.isna(flow_ts_utc):
return 0.0
# Look at recent trades for same symbol and expiry
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
symbol_mask = (df_sorted['symbol_norm'] == symbol) & \
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
if pd.notna(exp_date):
symbol_mask = symbol_mask & (df_sorted['exp_date'] == exp_date)
recent_trades = df_sorted[symbol_mask].tail(20)
if len(recent_trades) <= 1:
return 0.0
score = 0.0
# Exact strike clustering (0-40 points)
# Multiple trades at same strike
same_strike_count = len(recent_trades[recent_trades['strike_num'] == strike_num])
if len(recent_trades) > 0:
clustering_ratio = same_strike_count / len(recent_trades)
score += clustering_ratio * 40.0
# Strike laddering detection (0-35 points)
# Sequential strikes (e.g., 100, 105, 110) indicate laddering
unique_strikes = sorted(recent_trades['strike_num'].dropna().unique())
if len(unique_strikes) >= 3:
# Check if strikes are roughly evenly spaced (laddering)
if spot_num > 0:
strike_diffs = [abs(unique_strikes[i+1] - unique_strikes[i]) for i in range(len(unique_strikes)-1)]
if len(strike_diffs) > 0:
avg_diff = np.mean(strike_diffs)
std_diff = np.std(strike_diffs)
# Low std relative to mean = regular spacing (laddering)
if avg_diff > 0:
regularity = 1.0 - min(1.0, std_diff / avg_diff)
score += regularity * 35.0
# Proximity to spot (0-25 points)
# Strikes near spot price = pin risk, more significant
if spot_num > 0:
strike_pct_diff = abs(strike_num - spot_num) / spot_num * 100.0
# Within 2% = 25 points, within 5% = 15 points, within 10% = 5 points
if strike_pct_diff <= 2.0:
score += 25.0
elif strike_pct_diff <= 5.0:
score += 15.0
elif strike_pct_diff <= 10.0:
score += 5.0
return min(100.0, max(0.0, score))
def calculate_signal_strength(self, row: pd.Series) -> float:
"""
Calculate composite signal_strength from component scores.
signal_strength =
0.30 * aggression_score +
0.30 * size_concentration_score +
0.20 * repeat_trade_velocity +
0.20 * strike_clustering_score
"""
aggression = row.get('aggression_score', 0) or 0
size_conc = row.get('size_concentration_score', 0) or 0
repeat_vel = row.get('repeat_trade_velocity', 0) or 0
strike_clust = row.get('strike_clustering_score', 0) or 0
signal_strength = (
self.aggression_weight * aggression +
self.size_concentration_weight * size_conc +
self.repeat_velocity_weight * repeat_vel +
self.strike_clustering_weight * strike_clust
)
return min(100.0, max(0.0, signal_strength))
def enrich_with_signal_components(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Add signal component scores to DataFrame.
Adds: aggression_score, size_concentration_score, repeat_trade_velocity,
strike_clustering_score, signal_strength
"""
if df.empty:
return df
df = df.copy()
# Initialize columns
df['aggression_score'] = 0.0
df['size_concentration_score'] = 0.0
df['repeat_trade_velocity'] = 0.0
df['strike_clustering_score'] = 0.0
df['signal_strength'] = 0.0
# Calculate component scores (some require DataFrame context)
df['aggression_score'] = df.apply(self.calculate_aggression_score, axis=1)
df['size_concentration_score'] = df.apply(self.calculate_size_concentration_score, axis=1)
# These require DataFrame context, so iterate
for idx in df.index:
df.at[idx, 'repeat_trade_velocity'] = self.calculate_repeat_trade_velocity(df, idx)
df.at[idx, 'strike_clustering_score'] = self.calculate_strike_clustering_score(df, idx)
# Calculate composite signal_strength
df['signal_strength'] = df.apply(self.calculate_signal_strength, axis=1)
logger.info(f"Signal component scoring complete. Mean signal_strength: {df['signal_strength'].mean():.2f}")
return df

View File

@ -0,0 +1,319 @@
"""
Time-Sequenced Flow Analysis Service
Analyzes flow patterns over time to detect urgency, distribution, and continuation signals
"""
import pandas as pd
import numpy as np
from typing import Dict, Optional
from datetime import timedelta
from utils.logger import logger
class TimeSequencedAnalyzer:
"""
Analyzes flow patterns over time to detect:
- Flow acceleration (urgency building)
- Distribution patterns (flow weakening)
- Strike laddering (sequential accumulation)
"""
def __init__(self):
# Configuration
self.analysis_window_minutes = 60 # Look back window for flow analysis
self.min_trades_for_analysis = 3 # Minimum trades needed for meaningful analysis
def calculate_flow_acceleration(
self,
df: pd.DataFrame,
row_idx: int
) -> Optional[float]:
"""
Calculate flow acceleration: change in premium per minute.
Positive = accelerating (urgency building)
Negative = decelerating (flow weakening)
Returns: Δ premium / minute (premium rate change)
"""
if row_idx >= len(df):
return None
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
direction = current_row.get('direction')
flow_ts_utc = current_row.get('flow_ts_utc')
if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc):
return None
# Sort by timestamp
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
# Look at recent trades for same symbol and direction
window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes)
mask = (
(df_sorted['symbol_norm'] == symbol) &
(df_sorted['direction'] == direction) &
(df_sorted['flow_ts_utc'] >= window_start) &
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
)
recent_trades = df_sorted[mask]
if len(recent_trades) < self.min_trades_for_analysis:
return None
# Calculate premium accumulation over time
recent_trades = recent_trades.sort_values('flow_ts_utc')
recent_trades = recent_trades.copy()
recent_trades['cumulative_premium'] = recent_trades['premium_num'].cumsum()
# Fit linear trend to cumulative premium vs time
# Convert timestamps to minutes since window start
recent_trades['minutes_since_start'] = (
recent_trades['flow_ts_utc'] - window_start
).dt.total_seconds() / 60.0
# Calculate slope (premium per minute)
if len(recent_trades) >= 2:
x = recent_trades['minutes_since_start'].values
y = recent_trades['cumulative_premium'].values
# Linear regression: y = mx + b, we want m (slope)
# Calculate acceleration as change in slope (second derivative approximation)
if len(recent_trades) >= 3:
# Split into two halves
mid_idx = len(recent_trades) // 2
first_half = recent_trades.iloc[:mid_idx]
second_half = recent_trades.iloc[mid_idx:]
if len(first_half) >= 2 and len(second_half) >= 2:
# Calculate slopes for each half
x1 = first_half['minutes_since_start'].values
y1 = first_half['cumulative_premium'].values
slope1 = np.polyfit(x1, y1, 1)[0] if len(x1) > 1 else 0
x2 = second_half['minutes_since_start'].values
y2 = second_half['cumulative_premium'].values
slope2 = np.polyfit(x2, y2, 1)[0] if len(x2) > 1 else 0
# Acceleration = change in slope
acceleration = slope2 - slope1
return float(acceleration)
return None
def calculate_time_between_hits(
self,
df: pd.DataFrame,
row_idx: int
) -> Optional[float]:
"""
Calculate average time between consecutive trades (in minutes).
Lower = faster pace, higher urgency.
"""
if row_idx >= len(df):
return None
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
direction = current_row.get('direction')
flow_ts_utc = current_row.get('flow_ts_utc')
if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc):
return None
# Sort by timestamp
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
# Look at recent trades
window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes)
mask = (
(df_sorted['symbol_norm'] == symbol) &
(df_sorted['direction'] == direction) &
(df_sorted['flow_ts_utc'] >= window_start) &
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
)
recent_trades = df_sorted[mask].sort_values('flow_ts_utc')
if len(recent_trades) < 2:
return None
# Calculate time gaps between consecutive trades
time_gaps = []
prev_ts = None
for _, trade in recent_trades.iterrows():
ts = trade.get('flow_ts_utc')
if pd.notna(ts) and prev_ts:
gap_minutes = (ts - prev_ts).total_seconds() / 60.0
if gap_minutes > 0:
time_gaps.append(gap_minutes)
prev_ts = ts
if len(time_gaps) == 0:
return None
avg_time_between = np.mean(time_gaps)
return float(avg_time_between)
def calculate_follow_on_ratio(
self,
df: pd.DataFrame,
row_idx: int
) -> Optional[float]:
"""
Calculate follow-on ratio: fraction of trades in same direction after initial trade.
Higher = continuation, Lower = reversal/distribution.
Returns: ratio of same-direction trades / total trades (0-1)
"""
if row_idx >= len(df):
return None
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
direction = current_row.get('direction')
flow_ts_utc = current_row.get('flow_ts_utc')
if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc):
return None
# Sort by timestamp
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
# Look at trades after this one (within window)
window_end = flow_ts_utc + timedelta(minutes=self.analysis_window_minutes)
mask = (
(df_sorted['symbol_norm'] == symbol) &
(df_sorted['flow_ts_utc'] > flow_ts_utc) &
(df_sorted['flow_ts_utc'] <= window_end)
)
follow_on_trades = df_sorted[mask]
if len(follow_on_trades) == 0:
return None
# Count same-direction vs opposite-direction
same_direction = follow_on_trades[follow_on_trades['direction'] == direction]
follow_on_ratio = len(same_direction) / len(follow_on_trades) if len(follow_on_trades) > 0 else 0.0
return float(follow_on_ratio)
def detect_strike_laddering(
self,
df: pd.DataFrame,
row_idx: int
) -> bool:
"""
Detect strike laddering: sequential strikes in same direction.
Returns True if laddering pattern detected.
"""
if row_idx >= len(df):
return False
current_row = df.iloc[row_idx]
symbol = current_row.get('symbol_norm')
direction = current_row.get('direction')
cp_norm = current_row.get('cp_norm')
flow_ts_utc = current_row.get('flow_ts_utc')
exp_date = current_row.get('exp_date')
if pd.isna(symbol) or pd.isna(direction) or pd.isna(cp_norm):
return False
# Sort by timestamp
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
# Look at recent trades
window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes)
mask = (
(df_sorted['symbol_norm'] == symbol) &
(df_sorted['direction'] == direction) &
(df_sorted['cp_norm'] == cp_norm) &
(df_sorted['flow_ts_utc'] >= window_start) &
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
)
if pd.notna(exp_date):
mask = mask & (df_sorted['exp_date'] == exp_date)
recent_trades = df_sorted[mask].sort_values('flow_ts_utc')
if len(recent_trades) < 3:
return False
# Get unique strikes in order
strikes = recent_trades['strike_num'].dropna().unique()
strikes = sorted(strikes)
if len(strikes) < 3:
return False
# Check if strikes are sequential (laddering)
# Look for consistent spacing (e.g., 100, 105, 110 or 50, 55, 60)
strike_diffs = [strikes[i+1] - strikes[i] for i in range(len(strikes)-1)]
if len(strike_diffs) >= 2:
# Check if differences are roughly equal (within 20% variance)
avg_diff = np.mean(strike_diffs)
if avg_diff > 0:
std_diff = np.std(strike_diffs)
cv = std_diff / avg_diff if avg_diff > 0 else float('inf') # Coefficient of variation
# Low coefficient of variation = regular spacing = laddering
if cv < 0.2: # Less than 20% variation
return True
return False
def enrich_with_time_sequenced_metrics(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Add time-sequenced flow metrics to DataFrame.
Adds: flow_acceleration, time_between_hits, follow_on_ratio, strike_laddering_detected
"""
if df.empty:
return df
df = df.copy()
# Initialize columns
df['flow_acceleration'] = None
df['time_between_hits'] = None
df['follow_on_ratio'] = None
df['strike_laddering_detected'] = False
# Sort by timestamp for proper analysis
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
# Calculate metrics for each row
for idx in df_sorted.index:
original_idx = df_sorted.index[idx]
# Flow acceleration
acceleration = self.calculate_flow_acceleration(df_sorted, idx)
if acceleration is not None:
df.at[original_idx, 'flow_acceleration'] = float(acceleration)
# Time between hits
time_between = self.calculate_time_between_hits(df_sorted, idx)
if time_between is not None:
df.at[original_idx, 'time_between_hits'] = float(time_between)
# Follow-on ratio
follow_on = self.calculate_follow_on_ratio(df_sorted, idx)
if follow_on is not None:
df.at[original_idx, 'follow_on_ratio'] = float(follow_on)
# Strike laddering
laddering = self.detect_strike_laddering(df_sorted, idx)
df.at[original_idx, 'strike_laddering_detected'] = bool(laddering)
logger.info(f"Time-sequenced analysis complete. Strike laddering detected: {df['strike_laddering_detected'].sum()}")
return df

View File

@ -3,7 +3,13 @@
# Activate virtual environment if it exists # Activate virtual environment if it exists
if [ -d "venv" ]; then if [ -d "venv" ]; then
source venv/bin/activate # Try Windows Scripts path first (Git Bash on Windows)
if [ -f "venv/Scripts/activate" ]; then
source venv/Scripts/activate
# Fallback to Unix bin path (Linux/macOS)
elif [ -f "venv/bin/activate" ]; then
source venv/bin/activate
fi
fi fi
# Start the service # Start the service

View File

View File

@ -1,5 +1,6 @@
import express from 'express'; import express from 'express';
import { backtestSignal } from '../services/backtester.js'; import { backtestSignal } from '../services/backtester.js';
import { rawQuery } from '../db.js';
import crypto from 'crypto'; import crypto from 'crypto';
const router = express.Router(); const router = express.Router();
@ -27,9 +28,6 @@ router.post('/run', async (req, res) => {
const { const {
lookbackDays = 30, lookbackDays = 30,
exitStrategy = 'target_stop',
targetPct = 1.5,
stopPct = 1.5,
minOccurrences = 10 minOccurrences = 10
} = options; } = options;
@ -298,5 +296,646 @@ router.get('/presets', async (req, res) => {
} }
}); });
/**
* GET /api/backtest/date-analysis
* Get symbols with rocket scores 2 or 3 on a specific date
*/
router.get('/date-analysis', async (req, res) => {
try {
const { date, minRocketScore = 2, maxRocketScore = 3, minPremium = 80000 } = req.query;
// Parse numeric parameters
const minRocket = parseFloat(minRocketScore) || 2;
const maxRocket = parseFloat(maxRocketScore) || 3;
const minPrem = parseFloat(minPremium) || 80000;
if (!date) {
return res.status(400).json({
success: false,
error: 'Date parameter is required (YYYY-MM-DD)'
});
}
// Query to get symbols with rocket scores 2-3 on the date
const query = `
WITH base AS (
SELECT
ofm.ctid AS rid,
UPPER(TRIM(ofm."Symbol")) AS symbol_norm,
NULLIF(REGEXP_REPLACE(ofm."Spot"::text, '[\\s$,]', '', 'g'),'')::numeric AS spot_num,
NULLIF(REGEXP_REPLACE(ofm."Premium"::text, '[\\s$,]', '', 'g'),'')::numeric AS premium_num,
NULLIF(REGEXP_REPLACE(ofm."Volume"::text, '[\\s$,]', '', 'g'),'')::numeric AS vol_num,
NULLIF(REGEXP_REPLACE(ofm."OI"::text, '[\\s$,]', '', 'g'),'')::numeric AS oi_num,
NULLIF(REGEXP_REPLACE(ofm."Strike"::text, '[\\s$,]', '', 'g'),'')::numeric AS strike_num,
NULLIF(REGEXP_REPLACE(ofm."Dte"::text, '[\\s$,]', '', 'g'),'')::numeric AS dte_num,
NULLIF(REGEXP_REPLACE(ofm."ImpliedVolatility"::text, '[\\s$,]', '', 'g'),'')::numeric AS iv_num,
CASE WHEN UPPER(TRIM(ofm."CallPut")) IN ('C','CALL','CALLS','CE') THEN 'CALL'
WHEN UPPER(TRIM(ofm."CallPut")) IN ('P','PUT','PUTS','PE') THEN 'PUT'
END AS cp_norm,
CASE
WHEN UPPER(TRIM(ofm."Side")) IN ('A','AA','ASK','BUY','BOT','BTO','AT_ASK') THEN 'BUY'
WHEN UPPER(TRIM(ofm."Side")) IN ('B','BB','BID','SELL','SLD','STO','AT_BID') THEN 'SELL'
ELSE NULL
END AS side_norm,
CASE
WHEN ofm."CreatedDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN
to_timestamp(ofm."CreatedDate"::text || ' ' || COALESCE(btrim(ofm."CreatedTime"::text), '00:00:00'), 'YYYY-MM-DD HH24:MI:SS')
ELSE to_timestamp(ofm."CreatedDate"::text || ' ' || COALESCE(btrim(ofm."CreatedTime"::text), '00:00:00'), 'MM/DD/YYYY HH24:MI:SS')
END AS flow_ts_local,
CASE
WHEN ofm."ExpirationDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN ofm."ExpirationDate"::date
WHEN ofm."ExpirationDate"::text ~ '^\\d{1,2}/\\d{1,2}/\\d{2,4}$' THEN to_date(ofm."ExpirationDate"::text, 'MM/DD/YYYY')
ELSE NULL
END AS exp_date
FROM public."OptionsFlow_monthly" ofm
WHERE ofm."Premium" IS NOT NULL
AND btrim(ofm."Premium"::text) <> ''
AND ofm."StockEtf" = 'STOCK'
AND (
CASE
WHEN ofm."CreatedDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$'
THEN ofm."CreatedDate"::date
WHEN ofm."CreatedDate"::text ~ '^\\d{1,2}/\\d{1,2}/\\d{2,4}$'
THEN to_date(ofm."CreatedDate"::text, 'MM/DD/YYYY')
ELSE NULL
END
) = $1::date
),
enriched AS (
SELECT
b.*,
-- Calculate moneyness (ITM/OTM percentage)
CASE
WHEN b.strike_num IS NOT NULL AND b.spot_num IS NOT NULL AND b.spot_num > 0 THEN
CASE
WHEN b.cp_norm = 'CALL' THEN ((b.strike_num - b.spot_num) / b.spot_num) * 100
WHEN b.cp_norm = 'PUT' THEN ((b.spot_num - b.strike_num) / b.spot_num) * 100
ELSE NULL
END
ELSE NULL
END AS mny_pct,
-- Calculate DTE if not provided
CASE
WHEN b.dte_num IS NOT NULL THEN b.dte_num
WHEN b.exp_date IS NOT NULL AND b.flow_ts_local IS NOT NULL THEN
(b.exp_date::date - b.flow_ts_local::date)::integer
ELSE NULL
END AS dte_calc,
-- DTE bucket classification
CASE
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 3 THEN '0-3 DTE (urgent)'
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 7 THEN '4-7 DTE (short)'
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 30 THEN '8-30 DTE (medium)'
WHEN b.dte_num IS NOT NULL THEN '30+ DTE (long)'
WHEN b.exp_date IS NOT NULL AND b.flow_ts_local IS NOT NULL THEN
CASE
WHEN (b.exp_date::date - b.flow_ts_local::date)::integer <= 3 THEN '0-3 DTE (urgent)'
WHEN (b.exp_date::date - b.flow_ts_local::date)::integer <= 7 THEN '4-7 DTE (short)'
WHEN (b.exp_date::date - b.flow_ts_local::date)::integer <= 30 THEN '8-30 DTE (medium)'
ELSE '30+ DTE (long)'
END
ELSE NULL
END AS dte_bucket,
-- Volume/OI ratio
CASE
WHEN b.oi_num > 0 THEN b.vol_num / NULLIF(b.oi_num, 0)
ELSE NULL
END AS vol_oi_ratio,
-- ITM/OTM classification
CASE
WHEN b.strike_num IS NOT NULL AND b.spot_num IS NOT NULL THEN
CASE
WHEN b.cp_norm = 'CALL' AND b.strike_num <= b.spot_num THEN 'ITM'
WHEN b.cp_norm = 'PUT' AND b.strike_num >= b.spot_num THEN 'ITM'
ELSE 'OTM'
END
ELSE NULL
END AS moneyness_class
FROM base b
),
flow AS (
SELECT
e.*,
CASE
WHEN (e.cp_norm='CALL' AND e.side_norm='BUY') OR (e.cp_norm='PUT' AND e.side_norm='SELL') THEN 'BULL'
WHEN (e.cp_norm='PUT' AND e.side_norm='BUY') OR (e.cp_norm='CALL' AND e.side_norm='SELL') THEN 'BEAR'
ELSE NULL
END AS direction,
CASE
WHEN EXTRACT(HOUR FROM e.flow_ts_local)::int BETWEEN 4 AND 9
AND NOT (EXTRACT(HOUR FROM e.flow_ts_local)::int = 9 AND EXTRACT(MINUTE FROM e.flow_ts_local)::int >= 30) THEN 'PRE'
WHEN (EXTRACT(HOUR FROM e.flow_ts_local)::int = 9 AND EXTRACT(MINUTE FROM e.flow_ts_local)::int >= 30)
OR (EXTRACT(HOUR FROM e.flow_ts_local)::int > 9 AND EXTRACT(HOUR FROM e.flow_ts_local)::int < 16) THEN 'RTH'
WHEN EXTRACT(HOUR FROM e.flow_ts_local)::int BETWEEN 16 AND 20 THEN 'POST'
ELSE 'OFF'
END AS session_bucket
FROM enriched e
),
catalyst_check AS (
SELECT
f.*,
CASE WHEN EXISTS (
SELECT 1
FROM public."AlertStream_monthly" a
WHERE UPPER(TRIM(a."Ticker")) = f.symbol_norm
OR UPPER(TRIM(a."ticker")) = f.symbol_norm
OR UPPER(TRIM(a."Symbol")) = f.symbol_norm
OR UPPER(TRIM(a."symbol")) = f.symbol_norm
AND (
CASE
WHEN a."Date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."Date"::date
WHEN a."date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."date"::date
WHEN a."AlertDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."AlertDate"::date
WHEN a."alert_date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."alert_date"::date
ELSE NULL
END
) = $1::date
) THEN 1 ELSE 0 END AS catalyst_flag
FROM flow f
),
scored AS (
SELECT
c.*,
(CASE
WHEN c.premium_num >= 2000000 THEN 3.0
WHEN c.premium_num >= 800000 THEN 2.0
WHEN c.premium_num >= 200000 THEN 1.0
ELSE 0.0
END)
+ 1.2 * CASE WHEN COALESCE(c.vol_num,0) > COALESCE(c.oi_num,0) THEN 1 ELSE 0 END
+ 1.0 * CASE c.session_bucket WHEN 'RTH' THEN 1 WHEN 'POST' THEN 0.5 WHEN 'PRE' THEN 0.3 ELSE 0 END
+ 1.0 * c.catalyst_flag
+ 0.8 * CASE WHEN c.moneyness_class = 'OTM' THEN 1 ELSE 0 END
AS rocket_score
FROM catalyst_check c
WHERE c.premium_num >= $2
AND c.direction IS NOT NULL
AND c.spot_num IS NOT NULL
),
strike_clusters AS (
SELECT
symbol_norm,
MAX(trades_at_strike) as max_trades_at_single_strike
FROM (
SELECT
symbol_norm,
strike_num,
COUNT(*) as trades_at_strike
FROM scored
WHERE strike_num IS NOT NULL
GROUP BY symbol_norm, strike_num
) sc
GROUP BY symbol_norm
),
dte_buckets AS (
SELECT
symbol_norm,
dte_bucket,
COUNT(*) as bucket_count,
ROW_NUMBER() OVER (PARTITION BY symbol_norm ORDER BY COUNT(*) DESC) as rn
FROM scored
WHERE dte_bucket IS NOT NULL
GROUP BY symbol_norm, dte_bucket
),
symbol_agg AS (
SELECT
s.symbol_norm,
COUNT(*) as total_trades, -- Total option trades (individual flow records)
COUNT(DISTINCT DATE_TRUNC('hour', s.flow_ts_local) +
(EXTRACT(MINUTE FROM s.flow_ts_local)::int / 15) * INTERVAL '15 minutes') as signal_periods, -- Distinct 15-min periods
SUM(s.premium_num) as total_premium,
MAX(s.rocket_score) as max_rocket_score,
MIN(s.rocket_score) as min_rocket_score,
AVG(s.rocket_score) as avg_rocket_score,
MAX(s.spot_num) as entry_price,
STRING_AGG(DISTINCT s.direction, ', ') as directions,
STRING_AGG(DISTINCT s.session_bucket, ', ') as sessions,
-- BULL signals aggregation
COUNT(*) FILTER (WHERE s.direction = 'BULL') as bull_count,
COALESCE(SUM(s.premium_num) FILTER (WHERE s.direction = 'BULL'), 0) as bull_premium,
COALESCE(MIN(s.premium_num) FILTER (WHERE s.direction = 'BULL'), 0) as bull_min_premium,
COALESCE(MAX(s.premium_num) FILTER (WHERE s.direction = 'BULL'), 0) as bull_max_premium,
-- BEAR signals aggregation
COUNT(*) FILTER (WHERE s.direction = 'BEAR') as bear_count,
COALESCE(SUM(s.premium_num) FILTER (WHERE s.direction = 'BEAR'), 0) as bear_premium,
COALESCE(MIN(s.premium_num) FILTER (WHERE s.direction = 'BEAR'), 0) as bear_min_premium,
COALESCE(MAX(s.premium_num) FILTER (WHERE s.direction = 'BEAR'), 0) as bear_max_premium,
-- Predictive factors aggregation
-- Moneyness (ITM vs OTM)
COUNT(*) FILTER (WHERE s.moneyness_class = 'ITM') as itm_count,
COUNT(*) FILTER (WHERE s.moneyness_class = 'OTM') as otm_count,
AVG(s.mny_pct) FILTER (WHERE s.mny_pct IS NOT NULL) as avg_moneyness_pct,
-- DTE analysis
AVG(s.dte_calc) FILTER (WHERE s.dte_calc IS NOT NULL) as avg_dte,
db.dte_bucket as most_common_dte_bucket,
-- Session analysis
COUNT(*) FILTER (WHERE s.session_bucket = 'RTH') as rth_count,
COUNT(*) FILTER (WHERE s.session_bucket = 'PRE') as pre_count,
COUNT(*) FILTER (WHERE s.session_bucket = 'POST') as post_count,
-- Catalyst detection
COUNT(*) FILTER (WHERE s.catalyst_flag = 1) as catalyst_count,
CASE WHEN COUNT(*) > 0 THEN
ROUND(100.0 * COUNT(*) FILTER (WHERE s.catalyst_flag = 1) / COUNT(*), 1)
ELSE 0 END as pct_with_catalyst,
-- Volume/OI ratio
AVG(s.vol_oi_ratio) FILTER (WHERE s.vol_oi_ratio IS NOT NULL) as avg_vol_oi_ratio,
COUNT(*) FILTER (WHERE s.vol_num > s.oi_num) as vol_exceeds_oi_count,
-- Strike clustering (institutional layering)
COUNT(DISTINCT s.strike_num) as unique_strikes,
CASE WHEN COUNT(DISTINCT s.strike_num) > 0 THEN
ROUND(COUNT(*)::numeric / COUNT(DISTINCT s.strike_num), 1)
ELSE 0 END as avg_trades_per_strike,
COALESCE(sc.max_trades_at_single_strike, 0) as max_trades_at_single_strike,
-- IV analysis
AVG(s.iv_num) FILTER (WHERE s.iv_num IS NOT NULL) as avg_iv
FROM scored s
LEFT JOIN strike_clusters sc ON sc.symbol_norm = s.symbol_norm
LEFT JOIN dte_buckets db ON db.symbol_norm = s.symbol_norm AND db.rn = 1
WHERE s.rocket_score >= $3 AND s.rocket_score <= $4
GROUP BY s.symbol_norm, sc.max_trades_at_single_strike, db.dte_bucket
HAVING COUNT(*) >= 1
)
SELECT * FROM symbol_agg
ORDER BY total_trades DESC, total_premium DESC
LIMIT 100
`;
const results = await rawQuery(query, [date, minPrem, minRocket, maxRocket]);
res.json({
success: true,
date,
symbols: results,
count: results.length
});
} catch (error) {
console.error('Date analysis error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/backtest/symbol-performance
* Get price movement and profitability for a symbol after a specific date
*/
router.get('/symbol-performance', async (req, res) => {
const { symbol, entryDate, daysForward = 7 } = req.query;
try {
if (!symbol || !entryDate) {
return res.status(400).json({
success: false,
error: 'Symbol and entryDate parameters are required'
});
}
// Get entry price
const entryPriceQuery = `
SELECT close, open, high, low, volume
FROM public.prices_daily
WHERE UPPER(symbol) = UPPER($1)
AND "Date" = $2::date
LIMIT 1
`;
const entryPriceData = await rawQuery(entryPriceQuery, [symbol, entryDate]);
if (!entryPriceData || entryPriceData.length === 0) {
return res.status(404).json({
success: false,
error: `No price data found for ${symbol} on ${entryDate}`
});
}
const entryPrice = parseFloat(entryPriceData[0].close);
if (isNaN(entryPrice) || !entryPrice) {
return res.status(404).json({
success: false,
error: `Invalid entry price for ${symbol} on ${entryDate}`
});
}
// Get price history for the next N days
const endDate = new Date(entryDate);
endDate.setDate(endDate.getDate() + parseInt(daysForward));
const endDateStr = endDate.toISOString().split('T')[0];
const priceHistoryQuery = `
SELECT "Date", close, open, high, low, volume
FROM public.prices_daily
WHERE UPPER(symbol) = UPPER($1)
AND "Date" > $2::date
AND "Date" <= $3::date
ORDER BY "Date" ASC
`;
const priceHistory = await rawQuery(priceHistoryQuery, [symbol, entryDate, endDateStr]);
// Get signals for this symbol on entry date with all predictive factors
const signalsQuery = `
WITH base AS (
SELECT
ofm.ctid AS rid,
UPPER(TRIM(ofm."Symbol")) AS symbol_norm,
NULLIF(REGEXP_REPLACE(ofm."Spot"::text, '[\\s$,]', '', 'g'),'')::numeric AS spot_num,
NULLIF(REGEXP_REPLACE(ofm."Premium"::text, '[\\s$,]', '', 'g'),'')::numeric AS premium_num,
NULLIF(REGEXP_REPLACE(ofm."Volume"::text, '[\\s$,]', '', 'g'),'')::numeric AS vol_num,
NULLIF(REGEXP_REPLACE(ofm."OI"::text, '[\\s$,]', '', 'g'),'')::numeric AS oi_num,
NULLIF(REGEXP_REPLACE(ofm."Strike"::text, '[\\s$,]', '', 'g'),'')::numeric AS strike_num,
NULLIF(REGEXP_REPLACE(ofm."Dte"::text, '[\\s$,]', '', 'g'),'')::numeric AS dte_num,
CASE WHEN UPPER(TRIM(ofm."CallPut")) IN ('C','CALL','CALLS','CE') THEN 'CALL'
WHEN UPPER(TRIM(ofm."CallPut")) IN ('P','PUT','PUTS','PE') THEN 'PUT'
END AS cp_norm,
CASE
WHEN UPPER(TRIM(ofm."Side")) IN ('A','AA','ASK','BUY','BOT','BTO','AT_ASK') THEN 'BUY'
WHEN UPPER(TRIM(ofm."Side")) IN ('B','BB','BID','SELL','SLD','STO','AT_BID') THEN 'SELL'
ELSE NULL
END AS side_norm,
CASE
WHEN ofm."CreatedDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN
to_timestamp(ofm."CreatedDate"::text || ' ' || COALESCE(btrim(ofm."CreatedTime"::text), '00:00:00'), 'YYYY-MM-DD HH24:MI:SS')
ELSE to_timestamp(ofm."CreatedDate"::text || ' ' || COALESCE(btrim(ofm."CreatedTime"::text), '00:00:00'), 'MM/DD/YYYY HH24:MI:SS')
END AS flow_ts_local
FROM public."OptionsFlow_monthly" ofm
WHERE UPPER(TRIM(ofm."Symbol")) = UPPER($1)
AND (
CASE
WHEN ofm."CreatedDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$'
THEN ofm."CreatedDate"::date
WHEN ofm."CreatedDate"::text ~ '^\\d{1,2}/\\d{1,2}/\\d{2,4}$'
THEN to_date(ofm."CreatedDate"::text, 'MM/DD/YYYY')
ELSE NULL
END
) = $2::date
AND ofm."Premium" IS NOT NULL
AND btrim(ofm."Premium"::text) <> ''
),
enriched AS (
SELECT
b.*,
-- Calculate moneyness
CASE
WHEN b.strike_num IS NOT NULL AND b.spot_num IS NOT NULL AND b.spot_num > 0 THEN
CASE
WHEN b.cp_norm = 'CALL' THEN ((b.strike_num - b.spot_num) / b.spot_num) * 100
WHEN b.cp_norm = 'PUT' THEN ((b.spot_num - b.strike_num) / b.spot_num) * 100
ELSE NULL
END
ELSE NULL
END AS mny_pct,
-- ITM/OTM classification
CASE
WHEN b.strike_num IS NOT NULL AND b.spot_num IS NOT NULL THEN
CASE
WHEN b.cp_norm = 'CALL' AND b.strike_num <= b.spot_num THEN 'ITM'
WHEN b.cp_norm = 'PUT' AND b.strike_num >= b.spot_num THEN 'ITM'
ELSE 'OTM'
END
ELSE NULL
END AS moneyness_class,
-- DTE bucket
CASE
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 3 THEN '0-3 DTE'
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 7 THEN '4-7 DTE'
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 30 THEN '8-30 DTE'
WHEN b.dte_num IS NOT NULL THEN '30+ DTE'
ELSE NULL
END AS dte_bucket,
-- Volume/OI ratio
CASE
WHEN b.oi_num > 0 THEN b.vol_num / NULLIF(b.oi_num, 0)
ELSE NULL
END AS vol_oi_ratio,
-- Session bucket
CASE
WHEN EXTRACT(HOUR FROM b.flow_ts_local)::int BETWEEN 4 AND 9
AND NOT (EXTRACT(HOUR FROM b.flow_ts_local)::int = 9 AND EXTRACT(MINUTE FROM b.flow_ts_local)::int >= 30) THEN 'PRE'
WHEN (EXTRACT(HOUR FROM b.flow_ts_local)::int = 9 AND EXTRACT(MINUTE FROM b.flow_ts_local)::int >= 30)
OR (EXTRACT(HOUR FROM b.flow_ts_local)::int > 9 AND EXTRACT(HOUR FROM b.flow_ts_local)::int < 16) THEN 'RTH'
WHEN EXTRACT(HOUR FROM b.flow_ts_local)::int BETWEEN 16 AND 20 THEN 'POST'
ELSE 'OFF'
END AS session_bucket
FROM base b
),
flow AS (
SELECT
e.*,
CASE
WHEN (e.cp_norm='CALL' AND e.side_norm='BUY') OR (e.cp_norm='PUT' AND e.side_norm='SELL') THEN 'BULL'
WHEN (e.cp_norm='PUT' AND e.side_norm='BUY') OR (e.cp_norm='CALL' AND e.side_norm='SELL') THEN 'BEAR'
ELSE NULL
END AS direction
FROM enriched e
),
catalyst_check AS (
SELECT
f.*,
CASE WHEN EXISTS (
SELECT 1
FROM public."AlertStream_monthly" a
WHERE (UPPER(TRIM(a."Ticker")) = f.symbol_norm
OR UPPER(TRIM(a."ticker")) = f.symbol_norm
OR UPPER(TRIM(a."Symbol")) = f.symbol_norm
OR UPPER(TRIM(a."symbol")) = f.symbol_norm)
AND (
CASE
WHEN a."Date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."Date"::date
WHEN a."date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."date"::date
WHEN a."AlertDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."AlertDate"::date
WHEN a."alert_date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."alert_date"::date
ELSE NULL
END
) = $2::date
) THEN 1 ELSE 0 END AS catalyst_flag
FROM flow f
),
scored AS (
SELECT
c.*,
(CASE
WHEN c.premium_num >= 2000000 THEN 3.0
WHEN c.premium_num >= 800000 THEN 2.0
WHEN c.premium_num >= 200000 THEN 1.0
ELSE 0.0
END) AS rocket_score
FROM catalyst_check c
WHERE c.premium_num >= 80000
)
SELECT
symbol_norm,
COUNT(*) as signal_count,
SUM(premium_num) as total_premium,
MAX(rocket_score) as max_rocket_score,
AVG(rocket_score) as avg_rocket_score,
STRING_AGG(DISTINCT direction, ', ') as directions,
STRING_AGG(DISTINCT cp_norm, ', ') as option_types,
-- Predictive factors
COUNT(*) FILTER (WHERE moneyness_class = 'ITM') as itm_count,
COUNT(*) FILTER (WHERE moneyness_class = 'OTM') as otm_count,
AVG(mny_pct) FILTER (WHERE mny_pct IS NOT NULL) as avg_moneyness_pct,
AVG(dte_num) FILTER (WHERE dte_num IS NOT NULL) as avg_dte,
MODE() WITHIN GROUP (ORDER BY dte_bucket) as most_common_dte_bucket,
COUNT(*) FILTER (WHERE session_bucket = 'RTH') as rth_count,
COUNT(*) FILTER (WHERE session_bucket = 'PRE') as pre_count,
COUNT(*) FILTER (WHERE session_bucket = 'POST') as post_count,
COUNT(*) FILTER (WHERE catalyst_flag = 1) as catalyst_count,
CASE WHEN COUNT(*) > 0 THEN
ROUND(100.0 * COUNT(*) FILTER (WHERE catalyst_flag = 1) / COUNT(*), 1)
ELSE 0 END as pct_with_catalyst,
AVG(vol_oi_ratio) FILTER (WHERE vol_oi_ratio IS NOT NULL) as avg_vol_oi_ratio,
COUNT(*) FILTER (WHERE vol_num > oi_num) as vol_exceeds_oi_count,
COUNT(DISTINCT strike_num) as unique_strikes
FROM scored
GROUP BY symbol_norm
`;
let signals = [];
try {
signals = await rawQuery(signalsQuery, [symbol, entryDate]);
} catch (error) {
console.error('Error fetching signals:', error);
// Continue with empty signals array if query fails
signals = [];
}
// Calculate performance metrics
const priceData = priceHistory.map(p => {
const close = parseFloat(p.close) || 0;
const open = parseFloat(p.open) || 0;
const high = parseFloat(p.high) || 0;
const low = parseFloat(p.low) || 0;
const volume = parseFloat(p.volume || 0);
return {
date: p.Date,
close,
open,
high,
low,
volume,
change: close - entryPrice,
changePct: entryPrice > 0 ? ((close - entryPrice) / entryPrice) * 100 : 0
};
});
// Calculate profitability insights
const maxGain = priceData.length > 0
? Math.max(...priceData.map(p => p.changePct || 0))
: 0;
const maxLoss = priceData.length > 0
? Math.min(...priceData.map(p => p.changePct || 0))
: 0;
const finalGain = priceData.length > 0
? (priceData[priceData.length - 1].changePct || 0)
: 0;
// Determine if signals were profitable
const signalData = (signals && signals.length > 0) ? signals[0] : {};
const directions = signalData.directions || '';
const isBullish = directions.includes('BULL');
const isBearish = directions.includes('BEAR');
// Calculate separate profitability for each direction
const bullProfitable = finalGain > 0;
const bearProfitable = finalGain < 0;
let wouldBeProfitable = false;
let profitReason = '';
let detailedAnalysis = null;
if (isBullish && !isBearish) {
// Pure bullish signals
wouldBeProfitable = bullProfitable;
profitReason = wouldBeProfitable
? `✅ Price increased ${finalGain.toFixed(2)}% - Bullish signals would have been profitable`
: `❌ Price decreased ${Math.abs(finalGain).toFixed(2)}% - Bullish signals would have lost money`;
} else if (isBearish && !isBullish) {
// Pure bearish signals
wouldBeProfitable = bearProfitable;
profitReason = wouldBeProfitable
? `✅ Price decreased ${Math.abs(finalGain).toFixed(2)}% - Bearish signals would have been profitable`
: `❌ Price increased ${finalGain.toFixed(2)}% - Bearish signals would have lost money`;
} else {
// Mixed signals - analyze both directions
const priceMove = Math.abs(finalGain);
const significantMove = priceMove > 1;
// Determine overall profitability based on which direction had more premium
// If we can't determine, consider it mixed/unclear
wouldBeProfitable = significantMove && (bullProfitable || bearProfitable);
// Build detailed analysis for mixed signals
const bullResult = bullProfitable ? '✅ Profitable' : '❌ Loss';
const bearResult = bearProfitable ? '✅ Profitable' : '❌ Loss';
profitReason = `Mixed signals detected - Price moved ${finalGain >= 0 ? '+' : ''}${finalGain.toFixed(2)}%`;
detailedAnalysis = {
bullSignals: {
profitable: bullProfitable,
result: bullResult,
explanation: finalGain > 0
? `Price went UP ${finalGain.toFixed(2)}% - BULL signals would have been profitable`
: `Price went DOWN ${Math.abs(finalGain).toFixed(2)}% - BULL signals would have lost money`
},
bearSignals: {
profitable: bearProfitable,
result: bearResult,
explanation: finalGain < 0
? `Price went DOWN ${Math.abs(finalGain).toFixed(2)}% - BEAR signals would have been profitable`
: `Price went UP ${Math.abs(finalGain).toFixed(2)}% - BEAR signals would have lost money`
},
recommendation: finalGain < 0
? 'BEAR signals would have been profitable (price declined)'
: finalGain > 0
? 'BULL signals would have been profitable (price rose)'
: 'Price was flat - neither direction would have been profitable'
};
}
res.json({
success: true,
symbol: symbol.toUpperCase(),
entryDate,
daysForward: parseInt(daysForward),
entryPrice,
signals: signalData,
priceHistory: priceData,
performance: {
maxGain,
maxLoss,
finalGain,
wouldBeProfitable,
profitReason,
detailedAnalysis,
bestDay: priceData.length > 0
? priceData.reduce((best, current) => {
const bestPct = Math.abs(best.changePct || 0);
const currentPct = Math.abs(current.changePct || 0);
return currentPct > bestPct ? current : best;
}, priceData[0])
: null
}
});
} catch (error) {
console.error('Symbol performance error:', error);
console.error('Error details:', {
symbol,
entryDate,
daysForward,
stack: error.stack
});
res.status(500).json({
success: false,
error: error.message,
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
});
export default router; export default router;

View File

@ -0,0 +1,215 @@
/**
* Market Analysis Routes
* Exposes deep stock analysis for top 50 universe
* All data from free sources (Yahoo Finance, Finviz, SEC EDGAR RSS)
*/
import express from 'express';
import NodeCache from 'node-cache';
import { UNIVERSE, getSymbolMeta, classifyCapTier } from '../services/stockUniverseService.js';
import { fetchBasicQuote, fetchQuoteSummary, fetchTechnicals, fetchFullAnalysis } from '../services/fundamentalsService.js';
import { fetchInstitutionalHolders, fetchInsiderTransactions } from '../services/institutionalHoldersService.js';
import { fetchNewsForSymbol, fetchMarketNews } from '../services/newsService.js';
const router = express.Router();
const universeCache = new NodeCache({ stdTTL: 60 });
/**
* GET /api/market/universe
* Returns full top-50 list with price, change, cap tier
* Optional: ?cap=large|mid|small|etf ?sector=Technology
*/
router.get('/universe', async (req, res) => {
try {
const { cap, sector } = req.query;
const cacheKey = `universe_${cap || 'all'}_${sector || 'all'}`;
const cached = universeCache.get(cacheKey);
if (cached) {
return res.json({ success: true, data: cached, cached: true, updatedAt: new Date().toISOString() });
}
// Fetch quotes for all symbols concurrently (batched)
const CONCURRENCY = 8;
const results = [];
for (let i = 0; i < UNIVERSE.length; i += CONCURRENCY) {
const batch = UNIVERSE.slice(i, i + CONCURRENCY);
const batchResults = await Promise.allSettled(
batch.map(async (entry) => {
const quote = await fetchBasicQuote(entry.symbol);
const capTier = classifyCapTier(quote?.market_cap ?? null, entry.isETF);
return {
symbol: entry.symbol,
name: entry.name,
sector: entry.sector,
isETF: entry.isETF,
capTier,
price: quote?.price ?? null,
change: quote?.change ?? null,
change_pct: quote?.change_pct ?? null,
market_cap: quote?.market_cap ?? null,
volume: quote?.volume ?? null,
high_52w: null, // populated by quoteSummary if needed
low_52w: null,
market_state: quote?.market_state ?? null,
};
})
);
batchResults.forEach(r => {
if (r.status === 'fulfilled' && r.value) results.push(r.value);
});
// Small delay between batches
if (i + CONCURRENCY < UNIVERSE.length) {
await new Promise(r => setTimeout(r, 150));
}
}
// Apply filters
let filtered = results;
if (cap) {
const capUpper = cap.toUpperCase();
filtered = filtered.filter(r => r.capTier === capUpper);
}
if (sector) {
filtered = filtered.filter(r => r.sector.toLowerCase() === sector.toLowerCase());
}
// Sort: ETFs last, then by market cap desc
filtered.sort((a, b) => {
if (a.isETF !== b.isETF) return a.isETF ? 1 : -1;
return (b.market_cap || 0) - (a.market_cap || 0);
});
universeCache.set(cacheKey, filtered);
res.json({ success: true, data: filtered, count: filtered.length, updatedAt: new Date().toISOString() });
} catch (err) {
console.error('[market/universe]', err);
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/market/stock/:symbol
* Full deep analysis: quote + fundamentals + technicals
*/
router.get('/stock/:symbol', async (req, res) => {
try {
const symbol = req.params.symbol.toUpperCase();
const meta = getSymbolMeta(symbol);
const analysis = await fetchFullAnalysis(symbol);
res.json({
success: true,
data: {
...analysis,
meta,
capTier: classifyCapTier(analysis.quote?.market_cap ?? null, meta?.isETF ?? false),
}
});
} catch (err) {
console.error(`[market/stock/${req.params.symbol}]`, err);
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/market/stock/:symbol/news
* Financial news for a symbol from multiple free sources
*/
router.get('/stock/:symbol/news', async (req, res) => {
try {
const symbol = req.params.symbol.toUpperCase();
const news = await fetchNewsForSymbol(symbol);
res.json({ success: true, data: news, count: news.length, symbol });
} catch (err) {
console.error(`[market/news/${req.params.symbol}]`, err);
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/market/stock/:symbol/holders
* Institutional holders + insider transactions
*/
router.get('/stock/:symbol/holders', async (req, res) => {
try {
const symbol = req.params.symbol.toUpperCase();
const [institutional, insiders] = await Promise.allSettled([
fetchInstitutionalHolders(symbol),
fetchInsiderTransactions(symbol),
]);
res.json({
success: true,
data: {
institutional: institutional.status === 'fulfilled' ? institutional.value : null,
insiders: insiders.status === 'fulfilled' ? insiders.value : null,
symbol,
fetchedAt: new Date().toISOString(),
}
});
} catch (err) {
console.error(`[market/holders/${req.params.symbol}]`, err);
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/market/news
* Market-wide news (SPY, macro, broad market)
*/
router.get('/news', async (req, res) => {
try {
const news = await fetchMarketNews();
res.json({ success: true, data: news, count: news.length });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/market/leaderboard
* Top movers and worst performers from the universe
*/
router.get('/leaderboard', async (req, res) => {
try {
const cacheKey = 'leaderboard';
const cached = universeCache.get(cacheKey);
if (cached) return res.json({ success: true, data: cached });
const CONCURRENCY = 8;
const quotes = [];
for (let i = 0; i < UNIVERSE.length; i += CONCURRENCY) {
const batch = UNIVERSE.slice(i, i + CONCURRENCY);
const batchResults = await Promise.allSettled(
batch.map(async (entry) => {
const q = await fetchBasicQuote(entry.symbol);
return q ? { symbol: entry.symbol, name: entry.name, isETF: entry.isETF, ...q } : null;
})
);
batchResults.forEach(r => {
if (r.status === 'fulfilled' && r.value) quotes.push(r.value);
});
if (i + CONCURRENCY < UNIVERSE.length) await new Promise(r => setTimeout(r, 100));
}
const validQuotes = quotes.filter(q => q.change_pct != null);
const sorted = [...validQuotes].sort((a, b) => b.change_pct - a.change_pct);
const result = {
top_gainers: sorted.slice(0, 5),
top_losers: sorted.slice(-5).reverse(),
most_active: [...validQuotes].sort((a, b) => (b.volume || 0) - (a.volume || 0)).slice(0, 5),
};
universeCache.set(cacheKey, result);
res.json({ success: true, data: result, updatedAt: new Date().toISOString() });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
export default router;

View File

@ -1,6 +1,8 @@
import express from 'express'; import express from 'express';
import compression from 'compression'; import compression from 'compression';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import optionsFlowRouter from './routes/optionsFlow.js'; import optionsFlowRouter from './routes/optionsFlow.js';
import dailyAnalysisRouter from './routes/dailyAnalysis.js'; import dailyAnalysisRouter from './routes/dailyAnalysis.js';
import pricesRouter from './routes/prices.js'; import pricesRouter from './routes/prices.js';
@ -11,6 +13,7 @@ import tradePlansRouter from './routes/tradePlans.js';
import performanceRouter from './routes/performance.js'; import performanceRouter from './routes/performance.js';
import reversalsRouter from './routes/reversals.js'; import reversalsRouter from './routes/reversals.js';
import convergenceRouter from './routes/convergence.js'; import convergenceRouter from './routes/convergence.js';
import marketAnalysisRouter from './routes/marketAnalysis.js';
import backtestRouter from './routes/backtest.js'; import backtestRouter from './routes/backtest.js';
import healthRouter from './routes/health.js'; import healthRouter from './routes/health.js';
import { setupWebSocket } from './routes/websocket.js'; import { setupWebSocket } from './routes/websocket.js';
@ -54,9 +57,25 @@ app.use('/api/trade-plans', tradePlansRouter);
app.use('/api/performance', performanceRouter); app.use('/api/performance', performanceRouter);
app.use('/api/reversals', reversalsRouter); app.use('/api/reversals', reversalsRouter);
app.use('/api/convergence', convergenceRouter); app.use('/api/convergence', convergenceRouter);
app.use('/api/market', marketAnalysisRouter);
app.use('/api/backtest', backtestRouter); app.use('/api/backtest', backtestRouter);
app.use('/health', healthRouter); app.use('/health', healthRouter);
// Serve static frontend files in production
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const frontendPath = path.join(__dirname, '../../public');
app.use(express.static(frontendPath));
// Fallback all other routes to React's index.html
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/health')) {
return next();
}
res.sendFile(path.join(frontendPath, 'index.html'));
});
// Error handler (must be last) // Error handler (must be last)
app.use(errorHandler); app.use(errorHandler);

View File

@ -37,7 +37,7 @@ async function getFetch() {
} }
const BLACKBOX_API_URL = 'https://api.blackboxstocks.com/api/v2/download/downloadOptionsFlow'; const BLACKBOX_API_URL = 'https://api.blackboxstocks.com/api/v2/download/downloadOptionsFlow';
const BLACKBOX_API_TOKEN = process.env.BLACKBOX_API_TOKEN || 'eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoic3JlZGR5MDgxOUBnbWFpbC5jb20iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6Ijk3ZDA5NThjLTBhOTktNDA5OC05OWQwLWNkYTg3Njg5N2Q3ZiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL3NpZCI6ImViRTByZWZCUm10YTFqMmx1YzZwNHZKZGJqTEJKT1BrMVVJNnNXcmJFTWM9IiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlN1YnNjcmliZXIiLCJPcHRpb25zIiwiRXF1aXRpZXMiXSwiZXhwIjoxNzY3Mjg5MTI1fQ.JdsEjjrbsnRhvXgUNUu4K3ee_dava0ONfV_kyLK4Kmg'; const BLACKBOX_API_TOKEN = process.env.BLACKBOX_API_TOKEN || 'eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoic3JlZGR5MDgxOUBnbWFpbC5jb20iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6Ijk3ZDA5NThjLTBhOTktNDA5OC05OWQwLWNkYTg3Njg5N2Q3ZiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL3NpZCI6ImViRTByZWZCUm10YTFqMmx1YzZwNHZKZGJqTEJKT1BrMVVJNnNXcmJFTWM9IiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlN1YnNjcmliZXIiLCJPcHRpb25zIiwiRXF1aXRpZXMiXSwiZXhwIjoxNzY3NDUyMzgwfQ.48oftWtLRXsteaErfIwUhygYoeAmdl_hbPWZ7590wqQ';
/** /**
* Build default filters object for the downloadOptionsFlow endpoint * Build default filters object for the downloadOptionsFlow endpoint

View File

@ -0,0 +1,291 @@
/**
* Fundamentals Service
* Fetches deep trader metrics from Yahoo Finance using authenticated crumb requests
* Calculates RSI-14, MACD (12,26,9), SMAs from OHLCV data
*/
import NodeCache from 'node-cache';
import { classifyCapTier } from './stockUniverseService.js';
import { yfFetchAuth } from './yahooCrumb.js';
const fundamentalsCache = new NodeCache({ stdTTL: 60 });
const technicalCache = new NodeCache({ stdTTL: 300 });
const quoteSummaryCache = new NodeCache({ stdTTL: 120 });
const YF_BASE = 'https://query1.finance.yahoo.com';
/**
* Fetch basic quote (price, volume, change, market cap)
*/
export async function fetchBasicQuote(symbol) {
const cacheKey = `quote_${symbol}`;
const cached = fundamentalsCache.get(cacheKey);
if (cached) return cached;
const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=5d`;
try {
const data = await yfFetchAuth(url);
const result = data?.chart?.result?.[0];
if (!result) return null;
const meta = result.meta;
const quotes = result.indicators?.quote?.[0] || {};
const timestamps = result.timestamp || [];
const currentPrice = meta.regularMarketPrice ?? meta.previousClose ?? null;
const prevClose = meta.previousClose ?? null;
const change = currentPrice && prevClose ? currentPrice - prevClose : null;
const changePct = change && prevClose ? (change / prevClose) * 100 : null;
const history = [];
const startIdx = Math.max(0, timestamps.length - 5);
for (let i = startIdx; i < timestamps.length; i++) {
const v = quotes.volume?.[i];
const c = quotes.close?.[i];
if (c != null) {
history.push({
date: new Date(timestamps[i] * 1000).toISOString().split('T')[0],
open: quotes.open?.[i] ?? null,
high: quotes.high?.[i] ?? null,
low: quotes.low?.[i] ?? null,
close: c,
volume: v ?? null,
});
}
}
history.sort((a, b) => new Date(b.date) - new Date(a.date));
const q = {
symbol: symbol.toUpperCase(),
price: currentPrice,
prev_close: prevClose,
change,
change_pct: changePct ? Math.round(changePct * 100) / 100 : null,
open: meta.regularMarketOpen ?? null,
high: meta.regularMarketDayHigh ?? null,
low: meta.regularMarketDayLow ?? null,
volume: meta.regularMarketVolume ?? null,
market_cap: meta.marketCap ?? null,
currency: meta.currency ?? 'USD',
exchange: meta.exchangeName ?? null,
market_state: meta.marketState ?? null,
history,
};
fundamentalsCache.set(cacheKey, q);
return q;
} catch (err) {
console.warn(`[fundamentalsService] quote failed for ${symbol}:`, err.message);
return null;
}
}
/**
* Fetch full fundamentals via quoteSummary v10
*/
export async function fetchQuoteSummary(symbol) {
const cacheKey = `qs_${symbol}`;
const cached = quoteSummaryCache.get(cacheKey);
if (cached) return cached;
const modules = [
'summaryDetail',
'defaultKeyStatistics',
'financialData',
'recommendationTrend',
'earningsTrend'
].join(',');
const url = `${YF_BASE}/v10/finance/quoteSummary/${symbol}?modules=${modules}`;
try {
const data = await yfFetchAuth(url);
const result = data?.quoteSummary?.result?.[0];
if (!result) return null;
const sd = result.summaryDetail || {};
const dks = result.defaultKeyStatistics || {};
const fd = result.financialData || {};
const rt = result.recommendationTrend?.trend?.[0] || {};
const parsed = {
pe_ttm: sd.trailingPE?.raw ?? null,
pe_forward: sd.forwardPE?.raw ?? null,
price_to_book: dks.priceToBook?.raw ?? null,
price_to_sales: dks.priceToSalesTrailing12Months?.raw ?? null,
ev_ebitda: dks.enterpriseToEbitda?.raw ?? null,
eps_ttm: dks.trailingEps?.raw ?? null,
eps_forward: dks.forwardEps?.raw ?? null,
earnings_growth: fd.earningsGrowth?.raw ?? null,
revenue_growth: fd.revenueGrowth?.raw ?? null,
gross_margins: fd.grossMargins?.raw ?? null,
ebitda_margins: fd.ebitdaMargins?.raw ?? null,
operating_margins: fd.operatingMargins?.raw ?? null,
profit_margins: dks.profitMargins?.raw ?? null,
debt_to_equity: fd.debtToEquity?.raw ?? null,
current_ratio: fd.currentRatio?.raw ?? null,
quick_ratio: fd.quickRatio?.raw ?? null,
return_on_equity: fd.returnOnEquity?.raw ?? null,
return_on_assets: fd.returnOnAssets?.raw ?? null,
free_cash_flow: fd.freeCashflow?.raw ?? null,
total_revenue: fd.totalRevenue?.raw ?? null,
total_debt: fd.totalDebt?.raw ?? null,
beta: sd.beta?.raw ?? null,
market_cap: sd.marketCap?.raw ?? null,
shares_outstanding: dks.sharesOutstanding?.raw ?? null,
float_shares: dks.floatShares?.raw ?? null,
short_pct_float: dks.shortPercentOfFloat?.raw ?? null,
shares_short: dks.sharesShort?.raw ?? null,
held_pct_institutions: dks.heldPercentInstitutions?.raw ?? null,
held_pct_insiders: dks.heldPercentInsiders?.raw ?? null,
book_value: dks.bookValue?.raw ?? null,
week52_high: sd.fiftyTwoWeekHigh?.raw ?? null,
week52_low: sd.fiftyTwoWeekLow?.raw ?? null,
week52_change: dks.fiftyTwoWeekChange?.raw ?? null,
avg_volume_10d: sd.averageVolume10days?.raw ?? null,
avg_volume_3m: sd.averageVolume?.raw ?? null,
dividend_yield: sd.dividendYield?.raw ?? null,
payout_ratio: sd.payoutRatio?.raw ?? null,
target_high: fd.targetHighPrice?.raw ?? null,
target_low: fd.targetLowPrice?.raw ?? null,
target_mean: fd.targetMeanPrice?.raw ?? null,
target_median: fd.targetMedianPrice?.raw ?? null,
recommendation: fd.recommendationKey ?? null,
analyst_count: fd.numberOfAnalystOpinions?.raw ?? null,
rec_strong_buy: rt.strongBuy ?? 0,
rec_buy: rt.buy ?? 0,
rec_hold: rt.hold ?? 0,
rec_sell: rt.sell ?? 0,
rec_strong_sell: rt.strongSell ?? 0,
capTier: classifyCapTier(sd.marketCap?.raw ?? null),
};
quoteSummaryCache.set(cacheKey, parsed);
return parsed;
} catch (err) {
console.warn(`[fundamentalsService] quoteSummary failed for ${symbol}:`, err.message);
return null;
}
}
/**
* Fetch OHLCV history and calculate technical indicators
*/
export async function fetchTechnicals(symbol) {
const cacheKey = `tech_${symbol}`;
const cached = technicalCache.get(cacheKey);
if (cached) return cached;
const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=1y`;
try {
const data = await yfFetchAuth(url);
const result = data?.chart?.result?.[0];
if (!result) return null;
const closes = result.indicators?.quote?.[0]?.close?.filter(v => v != null) || [];
if (closes.length < 26) return null;
const rsi = calculateRSI(closes, 14);
const macd = calculateMACD(closes, 12, 26, 9);
const sma50 = closes.length >= 50 ? avg(closes.slice(-50)) : null;
const sma200 = closes.length >= 200 ? avg(closes.slice(-200)) : null;
const currentPrice = closes[closes.length - 1];
const result_data = {
rsi_14: rsi !== null ? Math.round(rsi * 100) / 100 : null,
macd_line: macd?.line ?? null,
macd_signal: macd?.signal ?? null,
macd_histogram: macd?.histogram ?? null,
sma_50: sma50 ? Math.round(sma50 * 100) / 100 : null,
sma_200: sma200 ? Math.round(sma200 * 100) / 100 : null,
above_sma50: sma50 ? currentPrice > sma50 : null,
above_sma200: sma200 ? currentPrice > sma200 : null,
pct_from_sma50: sma50 ? ((currentPrice - sma50) / sma50 * 100) : null,
pct_from_sma200: sma200 ? ((currentPrice - sma200) / sma200 * 100) : null,
};
technicalCache.set(cacheKey, result_data);
return result_data;
} catch (err) {
console.warn(`[fundamentalsService] technicals failed for ${symbol}:`, err.message);
return null;
}
}
/**
* Full deep analysis for a single symbol
*/
export async function fetchFullAnalysis(symbol) {
const [quote, fundamentals, technicals] = await Promise.allSettled([
fetchBasicQuote(symbol),
fetchQuoteSummary(symbol),
fetchTechnicals(symbol),
]);
return {
symbol: symbol.toUpperCase(),
quote: quote.status === 'fulfilled' ? quote.value : null,
fundamentals: fundamentals.status === 'fulfilled' ? fundamentals.value : null,
technicals: technicals.status === 'fulfilled' ? technicals.value : null,
fetchedAt: new Date().toISOString(),
};
}
// ─── Technical Indicator Calculations ───────────────────────────────────────
function avg(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
function calculateRSI(closes, period = 14) {
if (closes.length < period + 1) return null;
const recent = closes.slice(-period - 1);
let gains = 0, losses = 0;
for (let i = 1; i <= period; i++) {
const diff = recent[i] - recent[i - 1];
if (diff >= 0) gains += diff;
else losses += Math.abs(diff);
}
const avgGain = gains / period;
const avgLoss = losses / period;
if (avgLoss === 0) return 100;
const rs = avgGain / avgLoss;
return 100 - (100 / (1 + rs));
}
function ema(data, period) {
if (data.length < period) return [];
const k = 2 / (period + 1);
const result = [];
let emaVal = avg(data.slice(0, period));
result.push(emaVal);
for (let i = period; i < data.length; i++) {
emaVal = data[i] * k + emaVal * (1 - k);
result.push(emaVal);
}
return result;
}
function calculateMACD(closes, fast = 12, slow = 26, signal = 9) {
if (closes.length < slow + signal) return null;
const emaFast = ema(closes, fast);
const emaSlow = ema(closes, slow);
const diff = emaSlow.length - emaFast.length;
const macdLine = emaFast.slice(diff).map((v, i) => v - emaSlow[i]);
const signalLine = ema(macdLine, signal);
const lastMacd = macdLine[macdLine.length - 1];
const lastSignal = signalLine[signalLine.length - 1];
return {
line: Math.round(lastMacd * 10000) / 10000,
signal: Math.round(lastSignal * 10000) / 10000,
histogram: Math.round((lastMacd - lastSignal) * 10000) / 10000,
};
}

View File

@ -0,0 +1,124 @@
/**
* Institutional Holders Service
* Fetches top institutional holders, insider transactions using native fetch and crumb auth
*/
import NodeCache from 'node-cache';
import { yfFetchAuth } from './yahooCrumb.js';
const holdersCache = new NodeCache({ stdTTL: 3600 });
const insidersCache = new NodeCache({ stdTTL: 3600 });
const YF_BASE = 'https://query1.finance.yahoo.com';
/**
* Fetch top institutional holders for a symbol
*/
export async function fetchInstitutionalHolders(symbol) {
const cacheKey = `inst_${symbol}`;
const cached = holdersCache.get(cacheKey);
if (cached) return cached;
const url = `${YF_BASE}/v10/finance/quoteSummary/${symbol}?modules=institutionOwnership,majorHoldersBreakdown`;
try {
const data = await yfFetchAuth(url);
const result = data?.quoteSummary?.result?.[0];
if (!result) return null;
const io = result.institutionOwnership || {};
const mhb = result.majorHoldersBreakdown || {};
const holders = (io.ownershipList || []).map(h => ({
name: h.organization ?? 'Unknown',
pct_held: h.pctHeld?.raw ?? null,
shares: h.position?.raw ?? null,
value: h.value?.raw ?? null,
date_reported: h.reportDate?.fmt ?? null,
shares_change: h.change?.raw ?? null,
pct_change: h.pctChange?.raw ?? null,
direction: (h.change?.raw ?? 0) > 0 ? 'INCREASED' : (h.change?.raw ?? 0) < 0 ? 'DECREASED' : 'UNCHANGED',
}));
const summary = {
pct_held_institutions: mhb.institutionsPercentHeld?.raw ?? null,
pct_held_insiders: mhb.insidersPercentHeld?.raw ?? null,
pct_float_institutions: mhb.institutionsFloatPercentHeld?.raw ?? null,
institution_count: mhb.institutionsCount?.raw ?? null,
};
const parsedData = { holders, summary };
holdersCache.set(cacheKey, parsedData);
return parsedData;
} catch (err) {
console.warn(`[institutionalHolders] failed for ${symbol}:`, err.message);
return null;
}
}
/**
* Fetch recent insider transactions
*/
export async function fetchInsiderTransactions(symbol) {
const cacheKey = `insider_${symbol}`;
const cached = insidersCache.get(cacheKey);
if (cached) return cached;
const url = `${YF_BASE}/v10/finance/quoteSummary/${symbol}?modules=insiderTransactions,insiderHolders`;
try {
const data = await yfFetchAuth(url);
const result = data?.quoteSummary?.result?.[0];
if (!result) return null;
const it = result.insiderTransactions || {};
const ih = result.insiderHolders || {};
const transactions = (it.transactions || []).slice(0, 20).map(t => {
const typeText = t.transactionText ?? '';
const direction =
/sale|sell/i.test(typeText) ? 'SELL' :
/purchase|buy|acquisition/i.test(typeText) ? 'BUY' : 'OTHER';
return {
insider_name: t.filerName ?? 'Unknown',
title: t.filerRelation ?? null,
transaction_type: typeText,
shares: t.shares?.raw ?? null,
value: t.value?.raw ?? null,
start_date: t.startDate?.fmt ?? null,
ownership_type: t.ownership ?? null,
direction,
};
});
const now = Date.now();
const ninetyDaysAgo = now - 90 * 24 * 60 * 60 * 1000;
const recent = transactions.filter(t => t.start_date && new Date(t.start_date).getTime() > ninetyDaysAgo);
const buyCount = recent.filter(t => t.direction === 'BUY').length;
const sellCount = recent.filter(t => t.direction === 'SELL').length;
const parsedData = {
transactions,
insider_summary: {
buys_90d: buyCount,
sells_90d: sellCount,
net_sentiment: buyCount > sellCount ? 'BULLISH' : sellCount > buyCount ? 'BEARISH' : 'NEUTRAL',
},
holders: (ih.holders || []).slice(0, 10).map(h => ({
name: h.name ?? 'Unknown',
title: h.relation ?? null,
shares: h.positionDirect?.raw ?? null,
pct_total: h.pctHeld?.raw ?? null,
latest_transaction: h.transactionDescription ?? null,
latest_date: h.latestTransDate?.fmt ?? null,
})),
};
insidersCache.set(cacheKey, parsedData);
return parsedData;
} catch (err) {
console.warn(`[insiderTransactions] failed for ${symbol}:`, err.message);
return null;
}
}

View File

@ -0,0 +1,244 @@
/**
* News Service
* Aggregates financial news from free sources:
* - Yahoo Finance RSS per ticker
* - Google News RSS (filtered to financial keywords)
* - Finviz news table scraper
* - Seeking Alpha RSS
*/
import NodeCache from 'node-cache';
import { parseStringPromise } from 'xml2js';
const newsCache = new NodeCache({ stdTTL: 300 }); // 5min cache
const FINANCE_KEYWORDS = [
'earnings', 'revenue', 'profit', 'loss', 'EPS', 'guidance', 'forecast',
'analyst', 'upgrade', 'downgrade', 'buy', 'sell', 'hold', 'target',
'merger', 'acquisition', 'buyback', 'dividend', 'split', 'IPO', 'offering',
'FDA', 'approval', 'patent', 'lawsuit', 'SEC', 'investigation',
'Fed', 'rate', 'inflation', 'GDP', 'market', 'stock', 'shares',
'quarter', 'annual', 'fiscal', 'outlook', 'beat', 'miss', 'exceed',
'short', 'hedge', 'fund', 'institutional', 'investment', 'investor',
'growth', 'decline', 'surge', 'drop', 'rally', 'correction', 'pullback'
];
const DEFAULT_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
};
async function fetchRSS(url, timeout = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { headers: DEFAULT_HEADERS, signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
return parseStringPromise(text, { explicitArray: false, ignoreAttrs: false });
} finally {
clearTimeout(timer);
}
}
/**
* Extract items from parsed RSS feed
*/
function extractRSSItems(parsed, source) {
try {
const items = parsed?.rss?.channel?.item || parsed?.feed?.entry || [];
const list = Array.isArray(items) ? items : [items];
return list.map(item => ({
title: item.title?._ || item.title || '',
url: item.link?.href || item.link || item.guid?._ || item.guid || '',
publishedAt: item.pubDate || item.published || item.updated || '',
source,
snippet: item.description?._ || item.description || item.summary?._ || item.summary || '',
})).filter(i => i.title);
} catch {
return [];
}
}
/**
* Filter to only financial/business relevant articles
*/
function isFinancialNews(item) {
const text = `${item.title} ${item.snippet}`.toLowerCase();
return FINANCE_KEYWORDS.some(kw => text.includes(kw.toLowerCase()));
}
/**
* Scrape news from Finviz (no API key needed)
*/
async function fetchFinvizNews(symbol, timeout = 8000) {
const url = `https://finviz.com/quote.ashx?t=${symbol}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const items = [];
try {
const res = await fetch(url, {
headers: {
...DEFAULT_HEADERS,
'Accept': 'text/html,application/xhtml+xml',
},
signal: controller.signal
});
if (!res.ok) return [];
const html = await res.text();
// Extract news table rows using regex (no DOM parser needed)
// Finviz news rows look like: <tr class="cursor-pointer"><td ...><a href="..." class="tab-link">TITLE</a>...DATE</td>
const newsRowRegex = /<a[^>]+class="[^"]*tab-link[^"]*"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi;
const dateRegex = /<td[^>]*class="[^"]*news-date[^"]*"[^>]*>([^<]+)<\/td>/gi;
let match;
const dates = [];
while ((match = dateRegex.exec(html)) !== null) {
dates.push(match[1].trim());
}
let linkIdx = 0;
while ((match = newsRowRegex.exec(html)) !== null) {
const href = match[1];
const title = match[2].trim();
if (title && href && !href.startsWith('/') && title.length > 10) {
items.push({
title,
url: href,
publishedAt: dates[linkIdx] || '',
source: 'Finviz',
snippet: '',
});
linkIdx++;
}
}
return items.slice(0, 15);
} catch {
return [];
} finally {
clearTimeout(timer);
}
}
/**
* Assign basic sentiment based on keywords
*/
function assignSentiment(title) {
const text = title.toLowerCase();
const bullish = ['beat', 'surge', 'rally', 'upgrade', 'buy', 'strong', 'growth', 'profit', 'record', 'high', 'exceed', 'outperform', 'dividend', 'buyback'];
const bearish = ['miss', 'drop', 'fall', 'downgrade', 'sell', 'weak', 'loss', 'cut', 'warning', 'investigation', 'lawsuit', 'layoff', 'short', 'concern'];
const bullScore = bullish.filter(w => text.includes(w)).length;
const bearScore = bearish.filter(w => text.includes(w)).length;
if (bullScore > bearScore) return 'BULLISH';
if (bearScore > bullScore) return 'BEARISH';
return 'NEUTRAL';
}
/**
* Fetch all news for a symbol from multiple free sources
*/
export async function fetchNewsForSymbol(symbol) {
const cacheKey = `news_${symbol}`;
const cached = newsCache.get(cacheKey);
if (cached) return cached;
const sources = [
{
name: 'Yahoo Finance',
fetch: () => fetchRSS(`https://finance.yahoo.com/rss/headline?s=${symbol}`),
},
{
name: 'Google News',
fetch: () => fetchRSS(`https://news.google.com/rss/search?q=${symbol}+stock+market&hl=en-US&gl=US&ceid=US:en`),
},
{
name: 'Seeking Alpha',
fetch: () => fetchRSS(`https://seekingalpha.com/api/sa/combined/${symbol}.xml`),
},
];
const results = await Promise.allSettled([
...sources.map(s => s.fetch()),
fetchFinvizNews(symbol),
]);
let allItems = [];
// Process RSS sources
results.slice(0, sources.length).forEach((res, i) => {
if (res.status === 'fulfilled' && res.value) {
const items = extractRSSItems(res.value, sources[i].name);
allItems.push(...items);
}
});
// Process Finviz
const finvizResult = results[sources.length];
if (finvizResult.status === 'fulfilled') {
allItems.push(...(finvizResult.value || []));
}
// Filter to financial news only (except Yahoo Finance — already financial)
allItems = allItems.filter(item => {
if (item.source === 'Yahoo Finance' || item.source === 'Finviz') return true;
return isFinancialNews(item);
});
// Deduplicate by title similarity
const seen = new Set();
allItems = allItems.filter(item => {
const key = item.title.slice(0, 60).toLowerCase().replace(/[^a-z0-9]/g, '');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// Add sentiment and sort
allItems = allItems.map(item => ({
...item,
sentiment: assignSentiment(item.title),
symbol: symbol.toUpperCase(),
}));
// Sort by date (most recent first) — handle mixed date formats
allItems.sort((a, b) => {
try {
return new Date(b.publishedAt) - new Date(a.publishedAt);
} catch { return 0; }
});
const finalItems = allItems.slice(0, 25);
newsCache.set(cacheKey, finalItems);
return finalItems;
}
/**
* Fetch market-wide news (SPY, QQQ, broader market)
*/
export async function fetchMarketNews() {
const cacheKey = 'market_news';
const cached = newsCache.get(cacheKey);
if (cached) return cached;
try {
const [spyNews, marketRSS] = await Promise.allSettled([
fetchNewsForSymbol('SPY'),
fetchRSS('https://feeds.finance.yahoo.com/rss/2.0/headline?s=SPY,QQQ,^GSPC&region=US&lang=en-US'),
]);
const items = [
...(spyNews.status === 'fulfilled' ? spyNews.value : []),
].slice(0, 20);
newsCache.set(cacheKey, items);
return items;
} catch {
return [];
}
}

View File

@ -0,0 +1,112 @@
/**
* Stock Universe Service
* Manages the top 50 stocks + ETFs universe with cap tier classification
*/
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 60 }); // 60 second cache
// Cap tier thresholds
const CAP_TIERS = {
LARGE: 10_000_000_000, // > $10B
MID: 2_000_000_000, // $2B - $10B
// Below $2B = SMALL
};
// Master universe of top 50 stocks + ETFs
export const UNIVERSE = [
// Mega-cap Tech
{ symbol: 'AAPL', name: 'Apple Inc.', sector: 'Technology', isETF: false },
{ symbol: 'MSFT', name: 'Microsoft Corp.', sector: 'Technology', isETF: false },
{ symbol: 'NVDA', name: 'NVIDIA Corp.', sector: 'Technology', isETF: false },
{ symbol: 'AMZN', name: 'Amazon.com Inc.', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'GOOGL', name: 'Alphabet Inc.', sector: 'Technology', isETF: false },
{ symbol: 'META', name: 'Meta Platforms', sector: 'Technology', isETF: false },
{ symbol: 'TSLA', name: 'Tesla Inc.', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'AVGO', name: 'Broadcom Inc.', sector: 'Technology', isETF: false },
{ symbol: 'ORCL', name: 'Oracle Corp.', sector: 'Technology', isETF: false },
{ symbol: 'AMD', name: 'Advanced Micro Devices', sector: 'Technology', isETF: false },
// Financials
{ symbol: 'JPM', name: 'JPMorgan Chase', sector: 'Financials', isETF: false },
{ symbol: 'BAC', name: 'Bank of America', sector: 'Financials', isETF: false },
{ symbol: 'WFC', name: 'Wells Fargo', sector: 'Financials', isETF: false },
{ symbol: 'GS', name: 'Goldman Sachs', sector: 'Financials', isETF: false },
{ symbol: 'V', name: 'Visa Inc.', sector: 'Financials', isETF: false },
{ symbol: 'MA', name: 'Mastercard Inc.', sector: 'Financials', isETF: false },
// Healthcare
{ symbol: 'UNH', name: 'UnitedHealth Group', sector: 'Healthcare', isETF: false },
{ symbol: 'LLY', name: 'Eli Lilly & Co.', sector: 'Healthcare', isETF: false },
{ symbol: 'JNJ', name: 'Johnson & Johnson', sector: 'Healthcare', isETF: false },
{ symbol: 'ABBV', name: 'AbbVie Inc.', sector: 'Healthcare', isETF: false },
{ symbol: 'PFE', name: 'Pfizer Inc.', sector: 'Healthcare', isETF: false },
// Energy
{ symbol: 'XOM', name: 'Exxon Mobil', sector: 'Energy', isETF: false },
{ symbol: 'CVX', name: 'Chevron Corp.', sector: 'Energy', isETF: false },
// Industrials
{ symbol: 'CAT', name: 'Caterpillar Inc.', sector: 'Industrials', isETF: false },
{ symbol: 'HON', name: 'Honeywell Intl.', sector: 'Industrials', isETF: false },
// Consumer
{ symbol: 'WMT', name: 'Walmart Inc.', sector: 'Consumer Staples', isETF: false },
{ symbol: 'COST', name: 'Costco Wholesale', sector: 'Consumer Staples', isETF: false },
// Semiconductors
{ symbol: 'QCOM', name: 'Qualcomm Inc.', sector: 'Technology', isETF: false },
{ symbol: 'MU', name: 'Micron Technology', sector: 'Technology', isETF: false },
{ symbol: 'INTC', name: 'Intel Corp.', sector: 'Technology', isETF: false },
// Growth / Momentum
{ symbol: 'PLTR', name: 'Palantir Technologies', sector: 'Technology', isETF: false },
{ symbol: 'CRWD', name: 'CrowdStrike Holdings', sector: 'Technology', isETF: false },
{ symbol: 'SNOW', name: 'Snowflake Inc.', sector: 'Technology', isETF: false },
{ symbol: 'COIN', name: 'Coinbase Global', sector: 'Financials', isETF: false },
{ symbol: 'NFLX', name: 'Netflix Inc.', sector: 'Technology', isETF: false },
{ symbol: 'CRM', name: 'Salesforce Inc.', sector: 'Technology', isETF: false },
{ symbol: 'ADBE', name: 'Adobe Inc.', sector: 'Technology', isETF: false },
{ symbol: 'NOW', name: 'ServiceNow Inc.', sector: 'Technology', isETF: false },
{ symbol: 'MSTR', name: 'MicroStrategy Inc.', sector: 'Technology', isETF: false },
// Broad ETFs
{ symbol: 'SPY', name: 'SPDR S&P 500 ETF', sector: 'ETF', isETF: true },
{ symbol: 'QQQ', name: 'Invesco QQQ Trust', sector: 'ETF', isETF: true },
{ symbol: 'IWM', name: 'iShares Russell 2000', sector: 'ETF', isETF: true },
{ symbol: 'DIA', name: 'SPDR Dow Jones ETF', sector: 'ETF', isETF: true },
// Sector ETFs
{ symbol: 'XLF', name: 'Financial Select SPDR', sector: 'ETF', isETF: true },
{ symbol: 'XLE', name: 'Energy Select SPDR', sector: 'ETF', isETF: true },
{ symbol: 'XLK', name: 'Technology Select SPDR', sector: 'ETF', isETF: true },
{ symbol: 'XLV', name: 'Health Care Select SPDR', sector: 'ETF', isETF: true },
// Specialty ETFs
{ symbol: 'ARKK', name: 'ARK Innovation ETF', sector: 'ETF', isETF: true },
{ symbol: 'GLD', name: 'SPDR Gold Shares', sector: 'ETF', isETF: true },
{ symbol: 'TLT', name: 'iShares 20+ Yr Treasury', sector: 'ETF', isETF: true },
];
/**
* Classify market cap tier
*/
export function classifyCapTier(marketCap, isETF = false) {
if (isETF) return 'ETF';
if (!marketCap || marketCap === 0) return 'UNKNOWN';
if (marketCap >= CAP_TIERS.LARGE) return 'LARGE';
if (marketCap >= CAP_TIERS.MID) return 'MID';
return 'SMALL';
}
/**
* Get universe symbols list
*/
export function getUniverseSymbols() {
return UNIVERSE.map(s => s.symbol);
}
/**
* Get universe meta by symbol
*/
export function getSymbolMeta(symbol) {
return UNIVERSE.find(s => s.symbol === symbol.toUpperCase()) || null;
}
/**
* Get all universe entries
*/
export function getUniverse() {
return UNIVERSE;
}

View File

@ -0,0 +1,96 @@
/**
* Yahoo Finance Crumb Helper
* Fetches and caches the A3 cookie and crumb needed for v10/v11 API endpoints
*/
let crumb = null;
let cookie = null;
let crumbPromise = null;
const DEFAULT_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
};
async function fetchCrumbAndCookie() {
try {
// 1. Get Cookie
const response = await fetch('https://fc.yahoo.com', {
headers: DEFAULT_HEADERS,
redirect: 'manual' // We only need the headers
});
const setCookieHeader = response.headers.get('set-cookie');
if (!setCookieHeader) {
throw new Error('No set-cookie header received from fc.yahoo.com');
}
// Extract A3 cookie
const match = setCookieHeader.match(/(A3=[^;]+)/);
if (!match) {
throw new Error('A3 cookie not found in set-cookie header');
}
cookie = match[1];
// 2. Get Crumb
const crumbRes = await fetch('https://query1.finance.yahoo.com/v1/test/getcrumb', {
headers: {
...DEFAULT_HEADERS,
'Cookie': cookie,
}
});
if (!crumbRes.ok) {
throw new Error(`Failed to get crumb, status: ${crumbRes.status}`);
}
crumb = await crumbRes.text();
return { cookie, crumb };
} catch (err) {
console.error('[YahooCrumb] Error fetching crumb:', err.message);
cookie = null;
crumb = null;
throw err;
}
}
export async function getYahooAuth() {
if (cookie && crumb) {
return { cookie, crumb };
}
if (!crumbPromise) {
crumbPromise = fetchCrumbAndCookie().finally(() => {
crumbPromise = null;
});
}
return crumbPromise;
}
export async function yfFetchAuth(url) {
const { cookie: currentCookie, crumb: currentCrumb } = await getYahooAuth();
const separator = url.includes('?') ? '&' : '?';
const urlWithCrumb = `${url}${separator}crumb=${currentCrumb}`;
const res = await fetch(urlWithCrumb, {
headers: {
...DEFAULT_HEADERS,
'Cookie': currentCookie,
}
});
if (!res.ok) {
// If auth fails, we might need a new crumb
if (res.status === 401 || res.status === 403) {
crumb = null;
cookie = null;
throw new Error(`Auth failed (${res.status}), crumb invalidated`);
}
throw new Error(`Yahoo Finance HTTP ${res.status} for ${urlWithCrumb}`);
}
return res.json();
}

View File

@ -12,11 +12,9 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"zustand": "^4.4.7", "zustand": "^4.4.7",
"@tanstack/react-query": "^5.12.2", "@tanstack/react-query": "^5.12.2",
"lightweight-charts": "^4.1.3",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"tailwind-merge": "^2.1.0", "tailwind-merge": "^2.1.0",
"d3": "^7.8.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",

View File

@ -1,18 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import OptionsFlowPanel from '@/components/dashboard/OptionsFlowPanel'; import OptionsFlowPanel from '@/components/dashboard/OptionsFlowPanel';
import { DailyAnalysisPanel } from '@/components/dashboard/DailyAnalysisPanel';
import ScannerPanel from '@/components/dashboard/ScannerPanel'; import ScannerPanel from '@/components/dashboard/ScannerPanel';
import PhaseClassifierPanel from '@/components/dashboard/PhaseClassifierPanel';
import BacktestPanel from '@/components/dashboard/BacktestPanel'; import BacktestPanel from '@/components/dashboard/BacktestPanel';
import AlertsFeed from '@/components/dashboard/AlertsFeed'; import AlertsFeed from '@/components/dashboard/AlertsFeed';
import Watchlist from '@/components/dashboard/Watchlist'; import Watchlist from '@/components/dashboard/Watchlist';
import PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel'; import PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel';
import FlowInfoPanel from '@/components/dashboard/FlowInfoPanel';
import ReversalAlerts from '@/components/alerts/ReversalAlerts'; import ReversalAlerts from '@/components/alerts/ReversalAlerts';
import ConvergenceAlerts from '@/components/alerts/ConvergenceAlerts'; import ConvergenceAlerts from '@/components/alerts/ConvergenceAlerts';
import MarketScreenerPanel from '@/components/dashboard/MarketScreenerPanel';
import StockDetailPanel from '@/components/dashboard/StockDetailPanel';
import NewsFeedPanel from '@/components/dashboard/NewsFeedPanel';
export default function App() { export default function App() {
const [selectedSymbol, setSelectedSymbol] = useState(null);
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100"> <div className="min-h-screen bg-slate-950 text-slate-100">
<ConvergenceAlerts /> <ConvergenceAlerts />
@ -46,17 +48,14 @@ export default function App() {
<div className="grid grid-cols-12 gap-6"> <div className="grid grid-cols-12 gap-6">
{/* Left: Main Panels */} {/* Left: Main Panels */}
<div className="col-span-9"> <div className="col-span-9">
<Tabs defaultValue="flow" className="w-full"> <Tabs defaultValue="market" className="w-full">
<TabsList className="bg-slate-900 border border-slate-800"> <TabsList className="bg-slate-900 border border-slate-800">
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
📊 Market Analysis
</TabsTrigger>
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800"> <TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
🎯 Options Flow 🎯 Options Flow
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="phase" className="data-[state=active]:bg-slate-800">
🔥 Phase Classifier
</TabsTrigger>
<TabsTrigger value="daily" className="data-[state=active]:bg-slate-800">
📊 Daily Analysis
</TabsTrigger>
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800"> <TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
🔍 Multi-Signal Scanner 🔍 Multi-Signal Scanner
</TabsTrigger> </TabsTrigger>
@ -65,18 +64,20 @@ export default function App() {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="market" className="mt-6 space-y-6">
<MarketScreenerPanel onSelectSymbol={setSelectedSymbol} />
{selectedSymbol && (
<StockDetailPanel
symbol={selectedSymbol}
onClose={() => setSelectedSymbol(null)}
/>
)}
</TabsContent>
<TabsContent value="flow" className="mt-6"> <TabsContent value="flow" className="mt-6">
<OptionsFlowPanel /> <OptionsFlowPanel />
</TabsContent> </TabsContent>
<TabsContent value="phase" className="mt-6">
<PhaseClassifierPanel />
</TabsContent>
<TabsContent value="daily" className="mt-6">
<DailyAnalysisPanel />
</TabsContent>
<TabsContent value="scanner" className="mt-6"> <TabsContent value="scanner" className="mt-6">
<ScannerPanel /> <ScannerPanel />
</TabsContent> </TabsContent>
@ -87,15 +88,15 @@ export default function App() {
</Tabs> </Tabs>
</div> </div>
{/* Right: Flow Info, Watchlist & Alerts Feed */} {/* Right: News, Watchlist & Alerts Feed */}
<div className="col-span-3 space-y-6"> <div className="col-span-3 space-y-6">
<FlowInfoPanel /> <NewsFeedPanel />
<Watchlist /> <Watchlist />
<AlertsFeed /> <AlertsFeed />
</div> </div>
</div> </div>
{/* Bottom: Today's Signals */} {/* Bottom: Performance Tracking */}
<div className="mt-6"> <div className="mt-6">
<PerformanceTrackingPanel /> <PerformanceTrackingPanel />
</div> </div>

View File

@ -1,128 +1 @@
import { useEffect, useRef } from 'react'; export default function() { return null; }
import * as d3 from 'd3';
export default function FlowHeatmap({ data }) {
const svgRef = useRef();
useEffect(() => {
if (!data || data.length === 0) return;
const margin = { top: 20, right: 20, bottom: 60, left: 60 };
const width = 800 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
// Clear previous
d3.select(svgRef.current).selectAll('*').remove();
const svg = d3
.select(svgRef.current)
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Prepare data: group by strike and time
const strikeExtent = d3.extent(data, d => d.strike_num || d.strike || 0);
const timeExtent = d3.extent(data, d => new Date(d.flow_ts_utc || d.CreatedDate || d.created_at));
// X scale: time
const xScale = d3
.scaleTime()
.domain(timeExtent)
.range([0, width]);
// Y scale: strike
const yScale = d3
.scaleLinear()
.domain(strikeExtent)
.range([height, 0]);
// Color scale: premium
const colorScale = d3
.scaleSequential(d3.interpolateRdYlGn)
.domain([
d3.min(data, d => (d.direction === 'BEAR' ? -(d.premium_num || d.total_premium || 0) : (d.premium_num || d.total_premium || 0))),
d3.max(data, d => (d.direction === 'BULL' ? (d.premium_num || d.total_premium || 0) : -(d.premium_num || d.total_premium || 0))),
]);
// Draw rectangles
svg
.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('x', d => xScale(new Date(d.flow_ts_utc || d.CreatedDate || d.created_at)))
.attr('y', d => yScale(d.strike_num || d.strike || 0))
.attr('width', 10)
.attr('height', 10)
.attr('fill', d =>
colorScale(d.direction === 'BULL' ? (d.premium_num || d.total_premium || 0) : -(d.premium_num || d.total_premium || 0))
)
.attr('opacity', 0.7)
.on('mouseover', function (event, d) {
d3.select(this).attr('opacity', 1);
// Show tooltip
})
.on('mouseout', function () {
d3.select(this).attr('opacity', 0.7);
});
// Add axes
svg
.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(xScale))
.style('color', '#94a3b8');
svg
.append('g')
.call(d3.axisLeft(yScale))
.style('color', '#94a3b8');
// Labels
svg
.append('text')
.attr('x', width / 2)
.attr('y', height + 50)
.attr('text-anchor', 'middle')
.style('fill', '#94a3b8')
.text('Time');
svg
.append('text')
.attr('transform', 'rotate(-90)')
.attr('x', -height / 2)
.attr('y', -50)
.attr('text-anchor', 'middle')
.style('fill', '#94a3b8')
.text('Strike Price');
}, [data]);
if (!data || data.length === 0) {
return (
<div className="p-4 bg-slate-900 rounded-lg border border-slate-800">
<h3 className="text-lg font-semibold mb-4 text-slate-100">
Options Flow Heatmap
</h3>
<div className="flex items-center justify-center h-[400px] text-slate-400">
No flow data available
</div>
</div>
);
}
return (
<div className="p-4 bg-slate-900 rounded-lg border border-slate-800">
<h3 className="text-lg font-semibold mb-4 text-slate-100">
Options Flow Heatmap
</h3>
<svg ref={svgRef} className="w-full" />
<div className="mt-4 flex items-center justify-center gap-4 text-xs text-slate-400">
<span>🟢 Bullish Premium</span>
<span>🔴 Bearish Premium</span>
</div>
</div>
);
}

View File

@ -1,191 +1 @@
import { useEffect, useRef, useState } from 'react'; export default function() { return null; }
import { createChart } from 'lightweight-charts';
import { getApiUrl } from '@/config/api';
export default function PriceChart({ symbol, flowData }) {
const chartContainerRef = useRef();
const chartRef = useRef();
const [timeframe, setTimeframe] = useState('1m');
useEffect(() => {
if (!chartContainerRef.current) return;
// Create chart
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: 500,
layout: {
background: { color: '#0f172a' },
textColor: '#94a3b8',
},
grid: {
vertLines: { color: '#1e293b' },
horzLines: { color: '#1e293b' },
},
crosshair: {
mode: 1,
},
timeScale: {
borderColor: '#334155',
timeVisible: true,
secondsVisible: false,
},
rightPriceScale: {
borderColor: '#334155',
},
});
// Main candlestick series
const candleSeries = chart.addCandlestickSeries({
upColor: '#22c55e',
downColor: '#ef4444',
borderUpColor: '#22c55e',
borderDownColor: '#ef4444',
wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
});
// Volume series
const volumeSeries = chart.addHistogramSeries({
color: '#3b82f6',
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
});
chart.priceScale('volume').applyOptions({
scaleMargins: {
top: 0.8,
bottom: 0,
},
});
// Flow markers series (for options flow events)
const markerSeries = chart.addLineSeries({
color: 'transparent',
lineWidth: 0,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
});
chartRef.current = {
chart,
candleSeries,
volumeSeries,
markerSeries,
};
// Fetch and set price data
fetchPriceData(symbol, timeframe).then(data => {
if (data && data.candles) {
candleSeries.setData(data.candles);
}
if (data && data.volume) {
volumeSeries.setData(data.volume);
}
}).catch(err => {
console.error('Error fetching price data:', err);
});
// Add flow markers
if (flowData && flowData.length > 0) {
const markers = flowData.map(flow => ({
time: Math.floor(new Date(flow.flow_ts_utc || flow.CreatedDate || flow.created_at).getTime() / 1000),
position: flow.direction === 'BULL' ? 'belowBar' : 'aboveBar',
color: flow.direction === 'BULL' ? '#22c55e' : '#ef4444',
shape: flow.rocketScore >= 5 ? 'arrowUp' : 'circle',
text: `${flow.rocketDisplay || '🚀'} $${((flow.premium_num || flow.total_premium || 0) / 1000).toFixed(0)}K`,
size: (flow.premium_num || flow.total_premium || 0) > 1000000 ? 2 : 1,
}));
markerSeries.setMarkers(markers);
}
// Handle resize
const handleResize = () => {
if (chartContainerRef.current && chartRef.current) {
chartRef.current.chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (chartRef.current) {
chartRef.current.chart.remove();
}
};
}, [symbol, timeframe, flowData]);
if (!symbol) {
return (
<div className="flex items-center justify-center h-[500px] border border-slate-800 rounded-lg bg-slate-900/50">
<div className="text-slate-400">
Select a symbol to view price chart
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Timeframe Selector */}
<div className="flex items-center gap-2">
{['1m', '5m', '15m', '1h', '1D'].map(tf => (
<button
key={tf}
onClick={() => setTimeframe(tf)}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
timeframe === tf
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{tf}
</button>
))}
</div>
{/* Chart Container */}
<div
ref={chartContainerRef}
className="relative rounded-lg border border-slate-800 bg-slate-900/50"
/>
{/* Flow Legend */}
<div className="flex items-center gap-4 text-xs text-slate-400">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span>Bullish Flow</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span>Bearish Flow</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-yellow-500" />
<span>🚀🚀🚀 High Score</span>
</div>
</div>
</div>
);
}
async function fetchPriceData(symbol, timeframe) {
try {
const response = await fetch(
`${getApiUrl(`/api/prices/${symbol}`)}?timeframe=${timeframe}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching price data:', error);
return { candles: [], volume: [] };
}
}

View File

@ -1,154 +1,611 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { TrendingUp, TrendingDown, BarChart3, RefreshCw } from 'lucide-react'; import { TrendingUp, TrendingDown, RefreshCw, Calendar, Search, BarChart3 } from 'lucide-react';
import { getApiUrl } from '@/config/api'; import { getApiUrl } from '@/config/api';
export default function BacktestPanel() { export default function BacktestPanel() {
const [results, setResults] = useState([]); const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [loading, setLoading] = useState(true); const [minPremium, setMinPremium] = useState(80000);
const [symbols, setSymbols] = useState([]);
const [selectedSymbol, setSelectedSymbol] = useState(null);
const [performance, setPerformance] = useState(null);
const [loading, setLoading] = useState(false);
const [loadingPerformance, setLoadingPerformance] = useState(false);
useEffect(() => { // Fetch symbols for selected date
fetchBacktests(); const fetchSymbolsForDate = async (date, premium = minPremium) => {
}, []);
const fetchBacktests = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch(getApiUrl('/api/backtest/presets')); const response = await fetch(
getApiUrl(`/api/backtest/date-analysis?date=${date}&minRocketScore=2&maxRocketScore=3&minPremium=${premium}`)
);
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setResults(data.results); // Filter symbols that appeared more than 3 times (using total_trades or signal_periods)
const frequentSymbols = data.symbols.filter(s => (s.total_trades || s.signal_count || 0) > 3);
setSymbols(frequentSymbols);
} }
} catch (error) { } catch (error) {
console.error('Backtest fetch error:', error); console.error('Error fetching symbols:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (loading) { // Fetch performance for selected symbol
return ( const fetchSymbolPerformance = async (symbol, date) => {
<div className="text-center py-12"> setLoadingPerformance(true);
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div> setSelectedSymbol(symbol);
<div className="mt-4 text-slate-400">Running backtests...</div> try {
</div> const response = await fetch(
); getApiUrl(`/api/backtest/symbol-performance?symbol=${symbol}&entryDate=${date}&daysForward=7`)
} );
const data = await response.json();
if (data.success) {
setPerformance(data);
}
} catch (error) {
console.error('Error fetching performance:', error);
} finally {
setLoadingPerformance(false);
}
};
useEffect(() => {
if (selectedDate) {
fetchSymbolsForDate(selectedDate, minPremium);
}
}, [selectedDate, minPremium]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Strategy Backtests (90 Days)</h2> <h2 className="text-2xl font-bold">Historical Signal Backtest</h2>
<button <div className="flex items-center gap-4">
onClick={fetchBacktests} <div className="flex items-center gap-2">
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors flex items-center gap-2" <Calendar className="w-4 h-4 text-slate-400" />
disabled={loading} <input
> type="date"
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} /> value={selectedDate}
Refresh onChange={(e) => setSelectedDate(e.target.value)}
</button> className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200"
max={new Date().toISOString().split('T')[0]}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400 whitespace-nowrap">Min Premium:</span>
<div className="relative">
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 text-sm">$</span>
<input
type="number"
value={minPremium}
onChange={(e) => {
const value = parseInt(e.target.value) || 0;
setMinPremium(value);
}}
className="pl-7 pr-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 w-32"
placeholder="80000"
min="0"
step="1000"
/>
</div>
</div>
<button
onClick={() => fetchSymbolsForDate(selectedDate, minPremium)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors flex items-center gap-2"
disabled={loading}
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div> </div>
{results.length === 0 && !loading && ( {/* Date Selection Info */}
<div className="text-center py-12 text-slate-400"> <div className="p-4 bg-slate-900 border border-slate-800 rounded-lg">
No backtest results available. Try refreshing or check the backend logs. <p className="text-sm text-slate-400">
📅 Analyzing signals from <span className="font-semibold text-slate-200">{selectedDate}</span>
</p>
<p className="text-xs text-slate-500 mt-1">
Showing symbols with 2-3 rocket scores that had more than 3 option trades with premium &gt; ${(minPremium / 1000).toFixed(0)}K
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: Symbols List */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Search className="w-5 h-5" />
Symbols with High Signal Activity
</h3>
{loading ? (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<div className="mt-4 text-slate-400">Loading symbols...</div>
</div>
) : symbols.length === 0 ? (
<div className="text-center py-12 text-slate-400">
No symbols found for this date. Try a different date.
</div>
) : (
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{symbols.map((symbol) => (
<div
key={symbol.symbol_norm}
onClick={() => fetchSymbolPerformance(symbol.symbol_norm, selectedDate)}
className={`p-4 bg-slate-900 border rounded-lg cursor-pointer transition-all ${
selectedSymbol === symbol.symbol_norm
? 'border-blue-500 bg-slate-800'
: 'border-slate-800 hover:border-slate-700'
}`}
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-lg font-bold text-slate-200">{symbol.symbol_norm}</h4>
<Badge
variant={(symbol.total_trades || symbol.signal_count || 0) > 10 ? 'success' : 'warning'}
className="text-xs"
>
{symbol.total_trades || symbol.signal_count || 0} trades
{symbol.signal_periods ? ` (${symbol.signal_periods} periods)` : ''}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div>
<span className="text-slate-500">Total Premium:</span>
<span className="ml-2 text-slate-200 font-semibold">
${((Number(symbol.total_premium) || 0) / 1000000).toFixed(2)}M
</span>
</div>
<div>
<span className="text-slate-500">Avg Rocket:</span>
<span className="ml-2 text-slate-200 font-semibold">
{symbol.avg_rocket_score != null ? Number(symbol.avg_rocket_score).toFixed(1) : 'N/A'}
</span>
</div>
<div>
<span className="text-slate-500">Entry Price:</span>
<span className="ml-2 text-slate-200 font-semibold">
{symbol.entry_price != null ? `$${Number(symbol.entry_price).toFixed(2)}` : 'N/A'}
</span>
</div>
<div>
<span className="text-slate-500">Direction:</span>
<span className="ml-2 text-slate-200 font-semibold">
{symbol.directions || 'Mixed'}
</span>
</div>
</div>
{/* BULL/BEAR Breakdown */}
{(symbol.bull_count > 0 || symbol.bear_count > 0) && (
<div className="pt-3 border-t border-slate-700 space-y-2">
{symbol.bull_count > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-green-400 font-semibold">🟢 BULL:</span>
<span className="text-slate-300">{symbol.bull_count} signals</span>
</div>
<div className="text-right">
<div className="text-slate-200 font-semibold">
${((Number(symbol.bull_premium) || 0) / 1000000).toFixed(2)}M
</div>
<div className="text-slate-400 text-xs">
${((Number(symbol.bull_min_premium) || 0) / 1000).toFixed(0)}K - ${((Number(symbol.bull_max_premium) || 0) / 1000).toFixed(0)}K
</div>
</div>
</div>
)}
{symbol.bear_count > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-red-400 font-semibold">🔴 BEAR:</span>
<span className="text-slate-300">{symbol.bear_count} signals</span>
</div>
<div className="text-right">
<div className="text-slate-200 font-semibold">
${((Number(symbol.bear_premium) || 0) / 1000000).toFixed(2)}M
</div>
<div className="text-slate-400 text-xs">
${((Number(symbol.bear_min_premium) || 0) / 1000).toFixed(0)}K - ${((Number(symbol.bear_max_premium) || 0) / 1000).toFixed(0)}K
</div>
</div>
</div>
)}
</div>
)}
{/* Predictive Factors */}
<div className="pt-3 border-t border-slate-700 space-y-2">
<h6 className="text-xs font-semibold text-slate-400 mb-2">📊 Predictive Factors:</h6>
{/* Moneyness */}
{(symbol.itm_count > 0 || symbol.otm_count > 0) && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Moneyness:</span>
<div className="text-right">
<span className="text-green-400">{symbol.itm_count || 0} ITM</span>
<span className="text-slate-500 mx-1">/</span>
<span className="text-yellow-400">{symbol.otm_count || 0} OTM</span>
</div>
</div>
)}
{/* DTE */}
{symbol.avg_dte != null && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Avg DTE:</span>
<span className="text-slate-200 font-semibold">
{Number(symbol.avg_dte).toFixed(0)} days
{symbol.most_common_dte_bucket && (
<span className="text-slate-400 ml-1">({symbol.most_common_dte_bucket})</span>
)}
</span>
</div>
)}
{/* Session Distribution */}
{(symbol.rth_count > 0 || symbol.pre_count > 0 || symbol.post_count > 0) && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Session:</span>
<div className="text-right">
<span className="text-blue-400">{symbol.rth_count || 0} RTH</span>
{symbol.pre_count > 0 && (
<>
<span className="text-slate-500 mx-1">/</span>
<span className="text-purple-400">{symbol.pre_count} PRE</span>
</>
)}
{symbol.post_count > 0 && (
<>
<span className="text-slate-500 mx-1">/</span>
<span className="text-orange-400">{symbol.post_count} POST</span>
</>
)}
</div>
</div>
)}
{/* Catalyst */}
{symbol.catalyst_count > 0 && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500"> Catalyst:</span>
<span className="text-yellow-400 font-semibold">
{symbol.catalyst_count} signals ({Number(symbol.pct_with_catalyst || 0).toFixed(0)}%)
</span>
</div>
)}
{/* Volume/OI Ratio */}
{symbol.avg_vol_oi_ratio != null && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Vol/OI Ratio:</span>
<span className={`font-semibold ${
Number(symbol.avg_vol_oi_ratio) > 1 ? 'text-green-400' : 'text-slate-400'
}`}>
{Number(symbol.avg_vol_oi_ratio).toFixed(2)}x
{symbol.vol_exceeds_oi_count > 0 && (
<span className="text-slate-400 ml-1">
({symbol.vol_exceeds_oi_count} trades)
</span>
)}
</span>
</div>
)}
{/* Strike Clustering */}
{symbol.unique_strikes > 0 && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Strike Clustering:</span>
<span className="text-slate-200 font-semibold">
{symbol.unique_strikes} unique strikes
{symbol.avg_trades_per_strike > 0 && (
<span className="text-slate-400 ml-1">
({Number(symbol.avg_trades_per_strike).toFixed(1)} avg/strike)
</span>
)}
{symbol.max_trades_at_single_strike > 1 && (
<span className="text-blue-400 ml-1">
(max: {symbol.max_trades_at_single_strike})
</span>
)}
</span>
</div>
)}
{/* IV */}
{symbol.avg_iv != null && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Avg IV:</span>
<span className="text-slate-200 font-semibold">
{Number(symbol.avg_iv).toFixed(1)}%
</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div> </div>
)}
<div className="grid gap-4"> {/* Right: Performance Analysis */}
{results.map((result, idx) => ( <div className="space-y-4">
<div <h3 className="text-lg font-semibold flex items-center gap-2">
key={idx} <BarChart3 className="w-5 h-5" />
className="p-6 bg-slate-900 border border-slate-800 rounded-lg" Performance Analysis
> </h3>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">{result.name}</h3> {loadingPerformance ? (
<Badge <div className="text-center py-12">
variant={parseFloat(result.winRate) >= 60 ? 'success' : parseFloat(result.winRate) >= 50 ? 'warning' : 'destructive'} <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
className="text-lg px-3 py-1" <div className="mt-4 text-slate-400">Analyzing performance...</div>
>
{result.winRate}% Win Rate
</Badge>
</div> </div>
) : !performance ? (
<div className="grid grid-cols-4 gap-4 mb-4"> <div className="text-center py-12 text-slate-400">
<div className="text-center p-3 bg-slate-800/50 rounded"> Select a symbol to view performance analysis
<div className="text-2xl font-bold text-blue-400">
{result.totalOccurrences || 0}
</div>
<div className="text-xs text-slate-400 mt-1">Total Signals</div>
</div>
<div className="text-center p-3 bg-slate-800/50 rounded">
<div className="text-2xl font-bold text-green-400">
{result.avgWin || '0.00'}%
</div>
<div className="text-xs text-slate-400 mt-1">Avg Win</div>
</div>
<div className="text-center p-3 bg-slate-800/50 rounded">
<div className="text-2xl font-bold text-red-400">
{result.avgLoss || '0.00'}%
</div>
<div className="text-xs text-slate-400 mt-1">Avg Loss</div>
</div>
<div className="text-center p-3 bg-slate-800/50 rounded">
<div className={`text-2xl font-bold ${parseFloat(result.expectancy || 0) >= 0 ? 'text-purple-400' : 'text-orange-400'}`}>
{result.expectancy || '0.00'}%
</div>
<div className="text-xs text-slate-400 mt-1">Expectancy</div>
</div>
</div> </div>
) : (
<div className="grid grid-cols-2 gap-4"> <div className="space-y-4">
<div> {/* Summary Card */}
<div className="text-sm font-semibold text-green-400 mb-2 flex items-center gap-2"> <div className={`p-6 rounded-lg border-2 ${
<TrendingUp className="w-4 h-4" /> performance.performance.wouldBeProfitable
Best Conditions ? 'bg-green-900/20 border-green-600/50'
</div> : 'bg-red-900/20 border-red-600/50'
<ul className="space-y-1 text-xs text-slate-300"> }`}>
{result.bestConditions && result.bestConditions.length > 0 ? ( <div className="flex items-center justify-between mb-4">
result.bestConditions.map((cond, i) => ( <h4 className="text-xl font-bold">{performance.symbol}</h4>
<li key={i} className="flex items-center gap-2"> {performance.performance.wouldBeProfitable ? (
<span className="text-green-400"></span> <TrendingUp className="w-8 h-8 text-green-400" />
<span>{cond.condition}:</span>
<span className="font-semibold text-green-400">{cond.winRate}</span>
<span className="text-slate-500">({cond.occurrences} trades)</span>
</li>
))
) : ( ) : (
<li className="text-slate-500">No data available</li> <TrendingDown className="w-8 h-8 text-red-400" />
)} )}
</ul>
</div>
<div>
<div className="text-sm font-semibold text-red-400 mb-2 flex items-center gap-2">
<TrendingDown className="w-4 h-4" />
Worst Conditions
</div> </div>
<ul className="space-y-1 text-xs text-slate-300">
{result.worstConditions && result.worstConditions.length > 0 ? ( <div className="space-y-2">
result.worstConditions.map((cond, i) => ( <div className="flex justify-between">
<li key={i} className="flex items-center gap-2"> <span className="text-slate-400">Entry Price:</span>
<span className="text-red-400"></span> <span className="font-semibold">${Number(performance.entryPrice || 0).toFixed(2)}</span>
<span>{cond.condition}:</span> </div>
<span className="font-semibold text-red-400">{cond.winRate}</span> <div className="flex justify-between">
<span className="text-slate-500">({cond.occurrences} trades)</span> <span className="text-slate-400">Final Change:</span>
</li> <span className={`font-semibold ${
)) Number(performance.performance?.finalGain || 0) >= 0 ? 'text-green-400' : 'text-red-400'
) : ( }`}>
<li className="text-slate-500">No data available</li> {Number(performance.performance?.finalGain || 0) >= 0 ? '+' : ''}
)} {Number(performance.performance?.finalGain || 0).toFixed(2)}%
</ul> </span>
</div> </div>
</div> <div className="flex justify-between">
<span className="text-slate-400">Max Gain:</span>
<span className="font-semibold text-green-400">
+{Number(performance.performance?.maxGain || 0).toFixed(2)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Max Loss:</span>
<span className="font-semibold text-red-400">
{Number(performance.performance?.maxLoss || 0).toFixed(2)}%
</span>
</div>
</div>
{result.error && ( <div className="mt-4 p-3 bg-slate-800/50 rounded">
<div className="mt-4 p-3 bg-red-900/20 border border-red-600/50 rounded text-sm text-red-400"> <p className="text-sm text-slate-300">
Error: {result.error} {performance.performance.profitReason}
</p>
{/* Detailed Analysis for Mixed Signals */}
{performance.performance.detailedAnalysis && (
<div className="mt-4 pt-4 border-t border-slate-700">
<h6 className="text-xs font-semibold text-slate-400 mb-3">Mixed Signals Breakdown:</h6>
<div className="space-y-2">
<div className="flex items-start gap-2">
<span className="text-xs text-slate-500 w-16">BULL:</span>
<div className="flex-1">
<span className={`text-xs font-semibold ${
performance.performance.detailedAnalysis.bullSignals.profitable
? 'text-green-400'
: 'text-red-400'
}`}>
{performance.performance.detailedAnalysis.bullSignals.result}
</span>
<p className="text-xs text-slate-400 mt-1">
{performance.performance.detailedAnalysis.bullSignals.explanation}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<span className="text-xs text-slate-500 w-16">BEAR:</span>
<div className="flex-1">
<span className={`text-xs font-semibold ${
performance.performance.detailedAnalysis.bearSignals.profitable
? 'text-green-400'
: 'text-red-400'
}`}>
{performance.performance.detailedAnalysis.bearSignals.result}
</span>
<p className="text-xs text-slate-400 mt-1">
{performance.performance.detailedAnalysis.bearSignals.explanation}
</p>
</div>
</div>
<div className="mt-3 pt-2 border-t border-slate-700">
<p className="text-xs font-semibold text-blue-400">
💡 {performance.performance.detailedAnalysis.recommendation}
</p>
</div>
</div>
</div>
)}
</div>
</div> </div>
)}
</div> {/* Price Chart */}
))} {performance.priceHistory && performance.priceHistory.length > 0 && (
<div className="p-4 bg-slate-900 border border-slate-800 rounded-lg">
<h5 className="text-sm font-semibold mb-3 text-slate-300">Price Movement (7 Days)</h5>
<div className="space-y-2">
{performance.priceHistory.map((day) => (
<div key={day.date} className="flex items-center gap-2">
<div className="w-20 text-xs text-slate-400">
{new Date(day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</div>
<div className="flex-1 bg-slate-800 rounded h-6 relative">
<div
className={`h-full rounded ${
Number(day.changePct || 0) >= 0 ? 'bg-green-500/50' : 'bg-red-500/50'
}`}
style={{
width: `${Math.min(Math.abs(Number(day.changePct || 0)) * 10, 100)}%`,
marginLeft: Number(day.changePct || 0) < 0 ? 'auto' : '0',
marginRight: Number(day.changePct || 0) < 0 ? '0' : 'auto'
}}
/>
</div>
<div className={`w-16 text-xs text-right font-semibold ${
Number(day.changePct || 0) >= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{Number(day.changePct || 0) >= 0 ? '+' : ''}{Number(day.changePct || 0).toFixed(2)}%
</div>
<div className="w-20 text-xs text-slate-300 text-right">
${Number(day.close || 0).toFixed(2)}
</div>
</div>
))}
</div>
</div>
)}
{/* Signal Details */}
{performance.signals && (
<div className="p-4 bg-slate-900 border border-slate-800 rounded-lg">
<h5 className="text-sm font-semibold mb-3 text-slate-300">Signal Details</h5>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div>
<span className="text-slate-500">Signal Count:</span>
<span className="ml-2 text-slate-200 font-semibold">
{performance.signals.signal_count || 0}
</span>
</div>
<div>
<span className="text-slate-500">Total Premium:</span>
<span className="ml-2 text-slate-200 font-semibold">
${((Number(performance.signals.total_premium) || 0) / 1000000).toFixed(2)}M
</span>
</div>
<div>
<span className="text-slate-500">Max Rocket:</span>
<span className="ml-2 text-slate-200 font-semibold">
{performance.signals.max_rocket_score != null ? Number(performance.signals.max_rocket_score).toFixed(1) : 'N/A'}
</span>
</div>
<div>
<span className="text-slate-500">Direction:</span>
<span className="ml-2 text-slate-200 font-semibold">
{performance.signals.directions || 'Mixed'}
</span>
</div>
</div>
{/* Predictive Factors in Signal Details */}
{(performance.signals.itm_count > 0 || performance.signals.otm_count > 0 ||
performance.signals.avg_dte != null || performance.signals.catalyst_count > 0) && (
<div className="pt-3 border-t border-slate-700 space-y-2">
<h6 className="text-xs font-semibold text-slate-400 mb-2">📊 Signal Quality Factors:</h6>
{/* Moneyness */}
{(performance.signals.itm_count > 0 || performance.signals.otm_count > 0) && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Moneyness:</span>
<div className="text-right">
<span className="text-green-400">{performance.signals.itm_count || 0} ITM</span>
<span className="text-slate-500 mx-1">/</span>
<span className="text-yellow-400">{performance.signals.otm_count || 0} OTM</span>
</div>
</div>
)}
{/* DTE */}
{performance.signals.avg_dte != null && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Avg DTE:</span>
<span className="text-slate-200 font-semibold">
{Number(performance.signals.avg_dte).toFixed(0)} days
{performance.signals.most_common_dte_bucket && (
<span className="text-slate-400 ml-1">({performance.signals.most_common_dte_bucket})</span>
)}
</span>
</div>
)}
{/* Session Distribution */}
{(performance.signals.rth_count > 0 || performance.signals.pre_count > 0 || performance.signals.post_count > 0) && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Session:</span>
<div className="text-right">
<span className="text-blue-400">{performance.signals.rth_count || 0} RTH</span>
{performance.signals.pre_count > 0 && (
<>
<span className="text-slate-500 mx-1">/</span>
<span className="text-purple-400">{performance.signals.pre_count} PRE</span>
</>
)}
{performance.signals.post_count > 0 && (
<>
<span className="text-slate-500 mx-1">/</span>
<span className="text-orange-400">{performance.signals.post_count} POST</span>
</>
)}
</div>
</div>
)}
{/* Catalyst */}
{performance.signals.catalyst_count > 0 && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500"> Catalyst:</span>
<span className="text-yellow-400 font-semibold">
{performance.signals.catalyst_count} signals ({Number(performance.signals.pct_with_catalyst || 0).toFixed(0)}%)
</span>
</div>
)}
{/* Volume/OI Ratio */}
{performance.signals.avg_vol_oi_ratio != null && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Vol/OI Ratio:</span>
<span className={`font-semibold ${
Number(performance.signals.avg_vol_oi_ratio) > 1 ? 'text-green-400' : 'text-slate-400'
}`}>
{Number(performance.signals.avg_vol_oi_ratio).toFixed(2)}x
{performance.signals.vol_exceeds_oi_count > 0 && (
<span className="text-slate-400 ml-1">
({performance.signals.vol_exceeds_oi_count} trades)
</span>
)}
</span>
</div>
)}
{/* Strike Clustering */}
{performance.signals.unique_strikes > 0 && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Strike Clustering:</span>
<span className="text-slate-200 font-semibold">
{performance.signals.unique_strikes} unique strikes
</span>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,300 @@
import { useState, useEffect, useCallback } from 'react';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3010';
const CAP_TABS = [
{ id: 'all', label: '🌐 All', color: 'text-slate-300' },
{ id: 'large', label: '🏛️ Large Cap', color: 'text-blue-400' },
{ id: 'mid', label: '🏢 Mid Cap', color: 'text-purple-400' },
{ id: 'small', label: '🔬 Small Cap', color: 'text-orange-400' },
{ id: 'etf', label: '📦 ETFs', color: 'text-green-400' },
];
function fmtMktCap(n) {
if (n == null) return '—';
if (n >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
return `$${n.toFixed(0)}`;
}
function fmtVolume(n) {
if (n == null) return '—';
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
return `${n}`;
}
function ChangeBadge({ value }) {
if (value == null) return <span className="text-slate-500"></span>;
const positive = value >= 0;
return (
<span className={`font-semibold tabular-nums ${
positive ? 'text-emerald-400' : 'text-red-400'
}`}>
{positive ? '+' : ''}{value.toFixed(2)}%
</span>
);
}
function CapBadge({ tier }) {
const styles = {
LARGE: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
MID: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
SMALL: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
ETF: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
UNKNOWN: 'bg-slate-700 text-slate-400 border-slate-600',
};
return (
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
styles[tier] || styles.UNKNOWN
}`}>
{tier}
</span>
);
}
export default function MarketScreenerPanel({ onSelectSymbol }) {
const [activeTab, setActiveTab] = useState('all');
const [sortBy, setSortBy] = useState('market_cap');
const [sortDir, setSortDir] = useState('desc');
const [search, setSearch] = useState('');
const [data, setData] = useState([]);
const [leaderboard, setLeaderboard] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const fetchData = useCallback(async () => {
try {
const cap = activeTab === 'all' ? '' : activeTab;
const url = `${API_BASE}/api/market/universe${cap ? `?cap=${cap}` : ''}`;
const [universeRes, lbRes] = await Promise.allSettled([
fetch(url).then(r => r.json()),
fetch(`${API_BASE}/api/market/leaderboard`).then(r => r.json()),
]);
if (universeRes.status === 'fulfilled' && universeRes.value.success) {
setData(universeRes.value.data || []);
setLastUpdated(new Date());
}
if (lbRes.status === 'fulfilled' && lbRes.value.success) {
setLeaderboard(lbRes.value.data);
}
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [activeTab]);
useEffect(() => {
setLoading(true);
fetchData();
const interval = setInterval(fetchData, 60000);
return () => clearInterval(interval);
}, [fetchData]);
const processed = [...data]
.filter(r => {
if (!search) return true;
const q = search.toUpperCase();
return r.symbol.includes(q) || r.name.toUpperCase().includes(q) || r.sector?.toUpperCase().includes(q);
})
.sort((a, b) => {
const av = a[sortBy] ?? -Infinity;
const bv = b[sortBy] ?? -Infinity;
return sortDir === 'desc' ? bv - av : av - bv;
});
const toggleSort = (col) => {
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc');
else { setSortBy(col); setSortDir('desc'); }
};
const SortIcon = ({ col }) => {
if (sortBy !== col) return <span className="text-slate-600 ml-1"></span>;
return <span className="text-blue-400 ml-1">{sortDir === 'desc' ? '↓' : '↑'}</span>;
};
return (
<div className="space-y-4">
{/* Leaderboard strip */}
{leaderboard && (
<div className="grid grid-cols-3 gap-3">
<LeaderCard title="🚀 Top Gainers" items={leaderboard.top_gainers} type="gain" />
<LeaderCard title="📉 Top Losers" items={leaderboard.top_losers} type="loss" />
<LeaderCard title="🔥 Most Active" items={leaderboard.most_active} type="volume" />
</div>
)}
{/* Main screener card */}
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-3">
<h2 className="text-white font-bold text-lg">📊 Market Screener</h2>
{lastUpdated && (
<span className="text-xs text-slate-500">
Updated {lastUpdated.toLocaleTimeString()}
</span>
)}
{loading && <span className="text-xs text-blue-400 animate-pulse">Refreshing...</span>}
</div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Search symbol or name..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 w-52"
/>
<button
onClick={fetchData}
className="bg-slate-800 hover:bg-slate-700 border border-slate-600 text-slate-300 px-3 py-1.5 rounded-lg text-sm transition-colors"
>
</button>
</div>
</div>
{/* Cap tier tabs */}
<div className="flex border-b border-slate-700/50 bg-slate-950/30">
{CAP_TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-sm font-medium transition-all ${
activeTab === tab.id
? `border-b-2 border-blue-500 ${tab.color} bg-blue-500/5`
: 'text-slate-500 hover:text-slate-300'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
{error ? (
<div className="p-8 text-center text-red-400">
<p> {error}</p>
<button onClick={fetchData} className="mt-2 text-blue-400 underline text-sm">Retry</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-slate-500 text-xs uppercase tracking-wide bg-slate-950/50">
<th className="text-left px-4 py-3 font-medium">Symbol</th>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Sector</th>
<th
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => toggleSort('price')}
>Price <SortIcon col="price" /></th>
<th
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => toggleSort('change_pct')}
>Change <SortIcon col="change_pct" /></th>
<th
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => toggleSort('market_cap')}
>Mkt Cap <SortIcon col="market_cap" /></th>
<th
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => toggleSort('volume')}
>Volume <SortIcon col="volume" /></th>
<th className="text-center px-4 py-3 font-medium">Cap Tier</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loading && data.length === 0 ? (
Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="animate-pulse">
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 bg-slate-800 rounded w-full" />
</td>
))}
</tr>
))
) : processed.length === 0 ? (
<tr><td colSpan={8} className="px-4 py-12 text-center text-slate-500">No results found</td></tr>
) : (
processed.map(row => (
<tr
key={row.symbol}
onClick={() => onSelectSymbol?.(row.symbol)}
className="hover:bg-slate-800/40 cursor-pointer transition-colors group"
>
<td className="px-4 py-3">
<span className="font-bold text-white group-hover:text-blue-400 transition-colors">
{row.symbol}
</span>
</td>
<td className="px-4 py-3 text-slate-400 max-w-[180px] truncate">{row.name}</td>
<td className="px-4 py-3">
<span className="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded">
{row.sector}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums">
<span className="text-white font-medium">
{row.price != null ? `$${row.price.toFixed(2)}` : '—'}
</span>
</td>
<td className="px-4 py-3 text-right">
<ChangeBadge value={row.change_pct} />
</td>
<td className="px-4 py-3 text-right text-slate-300 tabular-nums">
{fmtMktCap(row.market_cap)}
</td>
<td className="px-4 py-3 text-right text-slate-400 tabular-nums">
{fmtVolume(row.volume)}
</td>
<td className="px-4 py-3 text-center">
<CapBadge tier={row.capTier} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Footer */}
<div className="px-4 py-2 border-t border-slate-700/50 bg-slate-950/30 flex justify-between items-center">
<span className="text-xs text-slate-600">{processed.length} of {data.length} symbols</span>
<span className="text-xs text-slate-600">Powered by Yahoo Finance Free data 60s refresh</span>
</div>
</div>
</div>
);
}
function LeaderCard({ title, items = [], type }) {
return (
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-3">
<div className="text-xs font-semibold text-slate-400 mb-2">{title}</div>
<div className="space-y-1.5">
{items.slice(0, 5).map(item => (
<div key={item.symbol} className="flex items-center justify-between">
<span className="text-white font-medium text-sm">{item.symbol}</span>
{type === 'volume' ? (
<span className="text-slate-400 text-xs tabular-nums">
{item.volume >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume}
</span>
) : (
<ChangeBadge value={item.change_pct} />
)}
</div>
))}
{items.length === 0 && <div className="text-slate-600 text-xs">Loading...</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { useState, useEffect } from 'react';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3010';
function SentimentDot({ sentiment }) {
if (sentiment === 'BULLISH') return <span className="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-2 flex-shrink-0" />;
if (sentiment === 'BEARISH') return <span className="inline-block w-2 h-2 rounded-full bg-red-400 mr-2 flex-shrink-0" />;
return <span className="inline-block w-2 h-2 rounded-full bg-slate-500 mr-2 flex-shrink-0" />;
}
export default function NewsFeedPanel() {
const [news, setNews] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchNews = () => {
fetch(`${API_BASE}/api/market/news`)
.then(r => r.json())
.then(d => { if (d.success) setNews(d.data || []); })
.catch(console.error)
.finally(() => setLoading(false));
};
fetchNews();
const interval = setInterval(fetchNews, 300000); // 5min
return () => clearInterval(interval);
}, []);
return (
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 border-b border-slate-700/50">
<h3 className="text-sm font-semibold text-white">📰 Market News</h3>
{loading && <span className="text-xs text-blue-400 animate-pulse">Loading...</span>}
</div>
<div className="divide-y divide-slate-800/30 max-h-[400px] overflow-y-auto">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="p-3 animate-pulse">
<div className="h-3 bg-slate-800 rounded mb-1.5" />
<div className="h-3 bg-slate-800 rounded w-2/3" />
</div>
))
) : news.length === 0 ? (
<div className="p-4 text-slate-500 text-xs text-center">No news available</div>
) : (
news.map((item, i) => (
<a
key={i}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 p-3 hover:bg-slate-800/40 transition-colors group"
>
<SentimentDot sentiment={item.sentiment} />
<div className="flex-1 min-w-0">
<div className="text-xs text-slate-300 group-hover:text-white transition-colors line-clamp-2 leading-snug">
{item.title}
</div>
<div className="text-xs text-slate-600 mt-1">{item.source}</div>
</div>
</a>
))
)}
</div>
</div>
);
}

View File

@ -291,28 +291,28 @@ export function OptionsFlowCardList({ data, onCardClick, selectedRow, reversalsB
{row.stockPrice && row.stockPrice.currentPrice && ( {row.stockPrice && row.stockPrice.currentPrice && (
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="font-mono text-base font-semibold text-slate-200"> <span className="font-mono text-base font-semibold text-slate-200">
${row.stockPrice.currentPrice.toFixed(2)} ${(typeof row.stockPrice.currentPrice === 'number' ? row.stockPrice.currentPrice : parseFloat(row.stockPrice.currentPrice) || 0).toFixed(2)}
</span> </span>
{row.stockPrice.changePercent !== undefined && ( {row.stockPrice.changePercent !== undefined && row.stockPrice.changePercent !== null && (
<span className={cn( <span className={cn(
"text-sm font-semibold", "text-sm font-semibold",
row.stockPrice.changePercent >= 0 (typeof row.stockPrice.changePercent === 'number' ? row.stockPrice.changePercent : parseFloat(row.stockPrice.changePercent) || 0) >= 0
? "text-green-400" ? "text-green-400"
: "text-red-400" : "text-red-400"
)}> )}>
{row.stockPrice.changePercent >= 0 ? '+' : ''} {(typeof row.stockPrice.changePercent === 'number' ? row.stockPrice.changePercent : parseFloat(row.stockPrice.changePercent) || 0) >= 0 ? '+' : ''}
{row.stockPrice.changePercent.toFixed(2)}% {(typeof row.stockPrice.changePercent === 'number' ? row.stockPrice.changePercent : parseFloat(row.stockPrice.changePercent) || 0).toFixed(2)}%
</span> </span>
)} )}
{row.stockPrice.change !== undefined && ( {row.stockPrice.change !== undefined && row.stockPrice.change !== null && (
<span className={cn( <span className={cn(
"text-sm", "text-sm",
row.stockPrice.change >= 0 (typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0) >= 0
? "text-green-400" ? "text-green-400"
: "text-red-400" : "text-red-400"
)}> )}>
({row.stockPrice.change >= 0 ? '+' : ''} ({(typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0) >= 0 ? '+' : ''}
{row.stockPrice.change.toFixed(2)}) {(typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0).toFixed(2)})
</span> </span>
)} )}
</div> </div>
@ -446,6 +446,197 @@ export function OptionsFlowCardList({ data, onCardClick, selectedRow, reversalsB
</div> </div>
)} )}
{/* Phase 1: Institutional Analytics */}
{(row.confidence_score !== null && row.confidence_score !== undefined) ||
(row.signal_strength !== null && row.signal_strength !== undefined) ||
(row.relative_premium_score !== null && row.relative_premium_score !== undefined) ? (
<div className="flex flex-col gap-1 text-xs pt-1 border-t border-slate-700/50">
<div className="text-slate-500 font-semibold uppercase tracking-wide text-[10px]">🏛 Institutional</div>
<div className="grid grid-cols-3 gap-1.5">
{row.confidence_score !== null && row.confidence_score !== undefined && (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-slate-500">Confidence</span>
<div className="flex items-center gap-1">
<div className="flex-1 bg-slate-800 rounded-full h-1">
<div
className={cn(
"h-1 rounded-full",
(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0) >= 70 ? "bg-green-500" :
(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0) >= 50 ? "bg-yellow-500" :
"bg-orange-500"
)}
style={{ width: `${Math.min(100, typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0)}%` }}
/>
</div>
<span className={cn(
"text-[10px] font-bold w-8 text-right",
(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0) >= 70 ? 'text-green-400' :
(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0) >= 50 ? 'text-yellow-400' :
'text-orange-400'
)}>
{Math.round(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0)}%
</span>
</div>
</div>
)}
{row.signal_strength !== null && row.signal_strength !== undefined && (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-slate-500">Strength</span>
<div className="flex items-center gap-1">
<div className="flex-1 bg-slate-800 rounded-full h-1">
<div
className={cn(
"h-1 rounded-full",
(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0) >= 70 ? "bg-purple-500" :
(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0) >= 50 ? "bg-blue-500" :
"bg-slate-500"
)}
style={{ width: `${Math.min(100, typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0)}%` }}
/>
</div>
<span className={cn(
"text-[10px] font-bold w-6 text-right",
(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0) >= 70 ? 'text-purple-400' :
(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0) >= 50 ? 'text-blue-400' :
'text-slate-400'
)}>
{Math.round(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0)}
</span>
</div>
</div>
)}
{row.relative_premium_score !== null && row.relative_premium_score !== undefined && (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-slate-500">Rel. Prem</span>
<div className="flex items-center gap-1">
<div className="flex-1 bg-slate-800 rounded-full h-1">
<div
className={cn(
"h-1 rounded-full",
(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0) >= 70 ? "bg-green-500" :
(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0) >= 50 ? "bg-yellow-500" :
"bg-orange-500"
)}
style={{ width: `${Math.min(100, typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0)}%` }}
/>
</div>
<span className={cn(
"text-[10px] font-bold w-6 text-right",
(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0) >= 70 ? 'text-green-400' :
(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0) >= 50 ? 'text-yellow-400' :
'text-orange-400'
)}>
{Math.round(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0)}
</span>
</div>
</div>
)}
</div>
</div>
) : null}
{/* Phase 3: Dealer & Regime Analytics */}
{(row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined) ||
row.market_regime || row.volatility_intent ? (
<div className="flex flex-col gap-1 text-xs pt-1 border-t border-slate-700/50">
<div className="text-slate-500 font-semibold uppercase tracking-wide text-[10px]">🎯 Dealer & Regime</div>
<div className="flex flex-wrap gap-1.5">
{row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined && (
<div className="flex items-center gap-1">
<span className="text-[10px] text-slate-500">Pressure:</span>
<span className={cn(
"text-[10px] font-bold",
(typeof row.dealer_hedge_pressure_score === 'number' ? row.dealer_hedge_pressure_score : parseFloat(row.dealer_hedge_pressure_score) || 0) >= 70 ? "text-red-400" :
(typeof row.dealer_hedge_pressure_score === 'number' ? row.dealer_hedge_pressure_score : parseFloat(row.dealer_hedge_pressure_score) || 0) >= 50 ? "text-orange-400" :
"text-yellow-400"
)}>
{Math.round(typeof row.dealer_hedge_pressure_score === 'number' ? row.dealer_hedge_pressure_score : parseFloat(row.dealer_hedge_pressure_score) || 0)}
</span>
</div>
)}
{row.market_regime && (
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0",
row.market_regime === 'TREND' ? "bg-green-500/20 border-green-500/50 text-green-400" :
row.market_regime === 'RANGE' ? "bg-yellow-500/20 border-yellow-500/50 text-yellow-400" :
"bg-red-500/20 border-red-500/50 text-red-400"
)}
>
{row.market_regime === 'TREND' ? '📈' : row.market_regime === 'RANGE' ? '↔️' : '⚡'} {row.market_regime}
</Badge>
)}
{row.volatility_intent && (
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0",
row.volatility_intent === 'LONG_VOL' ? "bg-green-500/20 border-green-500/50 text-green-400" :
row.volatility_intent === 'SHORT_VOL' ? "bg-red-500/20 border-red-500/50 text-red-400" :
row.volatility_intent === 'DIRECTIONAL' ? "bg-blue-500/20 border-blue-500/50 text-blue-400" :
"bg-orange-500/20 border-orange-500/50 text-orange-400"
)}
>
{row.volatility_intent === 'LONG_VOL' ? '📊' : row.volatility_intent === 'SHORT_VOL' ? '📉' : row.volatility_intent === 'DIRECTIONAL' ? '➡️' : '🔄'} {row.volatility_intent.replace('_', '/')}
</Badge>
)}
</div>
</div>
) : null}
{/* Time-Sequenced Metrics */}
{(row.flow_acceleration !== null && row.flow_acceleration !== undefined) ||
(row.time_between_hits !== null && row.time_between_hits !== undefined) ||
(row.follow_on_ratio !== null && row.follow_on_ratio !== undefined) ||
row.strike_laddering_detected ? (
<div className="flex flex-col gap-1 text-xs pt-1 border-t border-slate-700/50">
<div className="text-slate-500 font-semibold uppercase tracking-wide text-[10px]"> Time-Sequenced</div>
<div className="grid grid-cols-2 gap-1 text-[10px]">
{row.flow_acceleration !== null && row.flow_acceleration !== undefined && (
<div className="flex items-center gap-1">
<span className="text-slate-500">Accel:</span>
<span className={cn(
"font-bold",
(typeof row.flow_acceleration === 'number' ? row.flow_acceleration : parseFloat(row.flow_acceleration) || 0) > 0 ? "text-green-400" : "text-red-400"
)}>
{(typeof row.flow_acceleration === 'number' ? row.flow_acceleration : parseFloat(row.flow_acceleration) || 0) >= 1e6
? `${((typeof row.flow_acceleration === 'number' ? row.flow_acceleration : parseFloat(row.flow_acceleration) || 0) / 1e6).toFixed(1)}M/min`
: `${((typeof row.flow_acceleration === 'number' ? row.flow_acceleration : parseFloat(row.flow_acceleration) || 0) / 1e3).toFixed(0)}K/min`}
</span>
</div>
)}
{row.time_between_hits !== null && row.time_between_hits !== undefined && (
<div className="flex items-center gap-1">
<span className="text-slate-500">Gap:</span>
<span className="text-slate-300 font-semibold">
{(typeof row.time_between_hits === 'number' ? row.time_between_hits : parseFloat(row.time_between_hits) || 0).toFixed(1)}m
</span>
</div>
)}
{row.follow_on_ratio !== null && row.follow_on_ratio !== undefined && (
<div className="flex items-center gap-1">
<span className="text-slate-500">Follow:</span>
<span className={cn(
"font-bold",
(typeof row.follow_on_ratio === 'number' ? row.follow_on_ratio : parseFloat(row.follow_on_ratio) || 0) >= 0.7 ? "text-green-400" :
(typeof row.follow_on_ratio === 'number' ? row.follow_on_ratio : parseFloat(row.follow_on_ratio) || 0) >= 0.4 ? "text-yellow-400" :
"text-red-400"
)}>
{((typeof row.follow_on_ratio === 'number' ? row.follow_on_ratio : parseFloat(row.follow_on_ratio) || 0) * 100).toFixed(0)}%
</span>
</div>
)}
{row.strike_laddering_detected !== null && row.strike_laddering_detected !== undefined && row.strike_laddering_detected && (
<div className="flex items-center gap-1">
<span className="text-slate-500">Ladder:</span>
<span className="text-green-400 font-bold"></span>
</div>
)}
</div>
</div>
) : null}
{/* Signal */} {/* Signal */}
{signal && signal.signal !== 'NEUTRAL' && signal.signal !== 'WAIT' && ( {signal && signal.signal !== 'NEUTRAL' && signal.signal !== 'WAIT' && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">

View File

@ -20,6 +20,7 @@ import { getApiUrl } from '@/config/api';
// Column visibility configuration // Column visibility configuration
const COLUMN_GROUPS = { const COLUMN_GROUPS = {
core: ['Symbol', 'badges', 'signalQuality', 'reversal', 'convergence', 'Rocket', 'Momentum', 'TradeSignal', 'TradePlan', 'NetPremium', 'Premium', 'SignalTier', 'Checklist'], core: ['Symbol', 'badges', 'signalQuality', 'reversal', 'convergence', 'Rocket', 'Momentum', 'TradeSignal', 'TradePlan', 'NetPremium', 'Premium', 'SignalTier', 'Checklist'],
institutional: ['confidence_score', 'signal_strength', 'relative_premium_score'], // Phase 2: Institutional Analytics columns
option: ['CallPut', 'Strike', 'Spot', 'Moneyness', 'ExpirationDate'], option: ['CallPut', 'Strike', 'Spot', 'Moneyness', 'ExpirationDate'],
volume: ['Volume', 'OI', 'Price', 'Side'], volume: ['Volume', 'OI', 'Price', 'Side'],
price: ['PctVsPriorClose', 'PctVsRthOpen', 'Pct5m', 'Pct15m', 'PriceReaction5m', 'VWAPDistance'], price: ['PctVsPriorClose', 'PctVsRthOpen', 'Pct5m', 'Pct15m', 'PriceReaction5m', 'VWAPDistance'],
@ -54,6 +55,7 @@ export default function OptionsFlowPanel() {
const [reversals, setReversals] = useState([]); const [reversals, setReversals] = useState([]);
const [convergences, setConvergences] = useState([]); const [convergences, setConvergences] = useState([]);
const [filterNoise, setFilterNoise] = useState(true); // Filter retail noise by default const [filterNoise, setFilterNoise] = useState(true); // Filter retail noise by default
const [cardViewSymbolFilter, setCardViewSymbolFilter] = useState(''); // Client-side symbol filter for card view only
const columnSelectorRef = useRef(null); const columnSelectorRef = useRef(null);
const { data, loading, error, filteredCount, refetch } = useOptionsFlow({ const { data, loading, error, filteredCount, refetch } = useOptionsFlow({
@ -78,6 +80,11 @@ export default function OptionsFlowPanel() {
const flowLedToMove = row.flow_led_to_move === true; const flowLedToMove = row.flow_led_to_move === true;
const checklistScore = row.checklist_score || 0; const checklistScore = row.checklist_score || 0;
// Institutional analytics (Phase 1)
const confidenceScore = row.confidence_score || 0;
const signalStrength = row.signal_strength || 0;
const relativePremium = row.relative_premium_score || 0;
let score = 0; let score = 0;
score += rocketScore * 10; // 0-100 points score += rocketScore * 10; // 0-100 points
score += momentum * 0.5; // 0-50 points score += momentum * 0.5; // 0-50 points
@ -92,6 +99,11 @@ export default function OptionsFlowPanel() {
if (flowLedToMove) score += 15; // Flow led to move bonus if (flowLedToMove) score += 15; // Flow led to move bonus
score += checklistScore * 2; // Checklist score bonus (0-10 points) score += checklistScore * 2; // Checklist score bonus (0-10 points)
// Institutional analytics bonuses (Phase 1)
score += confidenceScore * 0.2; // 0-20 points (confidence_score is 0-100)
score += signalStrength * 0.15; // 0-15 points (signal_strength is 0-100)
score += relativePremium * 0.1; // 0-10 points (relative_premium_score is 0-100)
return score; return score;
}; };
@ -219,6 +231,24 @@ export default function OptionsFlowPanel() {
return filtered; return filtered;
}, [data, stockPricesData, activeFilters, reversalsBySymbol]); }, [data, stockPricesData, activeFilters, reversalsBySymbol]);
// Client-side symbol filter for card view only
const cardViewFilteredData = useMemo(() => {
if (viewMode !== 'cards' || !cardViewSymbolFilter.trim()) {
return filteredData;
}
const filterSymbol = cardViewSymbolFilter.trim().toUpperCase();
return filteredData.filter(row => {
const symbolNorm = (row.symbol_norm || '').toUpperCase();
const symbol = (row.Symbol || '').toUpperCase();
const symbolDisplay = (row.symbolDisplay || '').toUpperCase();
return symbolNorm.includes(filterSymbol) ||
symbol.includes(filterSymbol) ||
symbolDisplay.includes(filterSymbol);
});
}, [filteredData, viewMode, cardViewSymbolFilter]);
// Get top 5 trades for summary // Get top 5 trades for summary
// Filter: items repeated more than once, more than 2 rockets, highest premium // Filter: items repeated more than once, more than 2 rockets, highest premium
const topTrades = useMemo(() => { const topTrades = useMemo(() => {
@ -388,17 +418,17 @@ export default function OptionsFlowPanel() {
{stockPrice && stockPrice.currentPrice && ( {stockPrice && stockPrice.currentPrice && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-xs font-semibold text-slate-300"> <span className="font-mono text-xs font-semibold text-slate-300">
${stockPrice.currentPrice.toFixed(2)} ${(typeof stockPrice.currentPrice === 'number' ? stockPrice.currentPrice : parseFloat(stockPrice.currentPrice) || 0).toFixed(2)}
</span> </span>
{stockPrice.changePercent !== undefined && ( {stockPrice.changePercent !== undefined && stockPrice.changePercent !== null && (
<span className={cn( <span className={cn(
"text-xs font-medium", "text-xs font-medium",
stockPrice.changePercent >= 0 (typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0) >= 0
? "text-green-400" ? "text-green-400"
: "text-red-400" : "text-red-400"
)}> )}>
{stockPrice.changePercent >= 0 ? '+' : ''} {(typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0) >= 0 ? '+' : ''}
{stockPrice.changePercent.toFixed(2)}% {(typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0).toFixed(2)}%
</span> </span>
)} )}
</div> </div>
@ -513,7 +543,8 @@ export default function OptionsFlowPanel() {
}, },
cell: ({ row }) => { cell: ({ row }) => {
const rocket = row.original.Rocket || row.original.rocketDisplay || ''; const rocket = row.original.Rocket || row.original.rocketDisplay || '';
const score = row.original.rocketScore || row.original.rocket_score || 0; const scoreRaw = row.original.rocketScore || row.original.rocket_score || 0;
const score = typeof scoreRaw === 'number' ? scoreRaw : parseFloat(scoreRaw) || 0;
const maxScore = 10; // Assuming max score is 10 const maxScore = 10; // Assuming max score is 10
const percentage = Math.min((score / maxScore) * 100, 100); const percentage = Math.min((score / maxScore) * 100, 100);
@ -788,9 +819,10 @@ export default function OptionsFlowPanel() {
// Add temporal explanation // Add temporal explanation
if (trendValue === 'DEAD') { if (trendValue === 'DEAD') {
if (minutesAgo !== null && minutesAgo >= 60) { if (minutesAgo !== null && minutesAgo >= 60) {
const premiumText = premium >= 1000000 const numPremium = typeof premium === 'number' ? premium : parseFloat(premium) || 0;
? `$${(premium / 1000000).toFixed(1)}M premium` const premiumText = numPremium >= 1000000
: `$${(premium / 1000).toFixed(0)}K premium`; ? `$${(numPremium / 1000000).toFixed(1)}M premium`
: `$${(numPremium / 1000).toFixed(0)}K premium`;
config.tooltip = `Initial positioning detected (${premiumText}), but accumulation stopped. Last flow ${minutesAgo}min ago - no continuation detected.`; config.tooltip = `Initial positioning detected (${premiumText}), but accumulation stopped. Last flow ${minutesAgo}min ago - no continuation detected.`;
config.temporalExplanation = `Flow occurred earlier; no continuation in last ${minutesAgo}min`; config.temporalExplanation = `Flow occurred earlier; no continuation in last ${minutesAgo}min`;
} else { } else {
@ -1006,9 +1038,11 @@ export default function OptionsFlowPanel() {
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.PctVsPriorClose; const val = row.original.PctVsPriorClose;
if (val === null || val === undefined) return '—'; if (val === null || val === undefined) return '—';
const numVal = typeof val === 'number' ? val : parseFloat(val);
if (isNaN(numVal)) return '—';
return ( return (
<span className={`font-medium ${val > 0 ? 'text-green-400' : 'text-red-400'}`}> <span className={`font-medium ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
{val > 0 ? '+' : ''}{val.toFixed(2)}% {numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
</span> </span>
); );
} }
@ -1020,9 +1054,11 @@ export default function OptionsFlowPanel() {
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.PctVsRthOpen || row.original.pct_vs_rth_open; const val = row.original.PctVsRthOpen || row.original.pct_vs_rth_open;
if (val === null || val === undefined) return '—'; if (val === null || val === undefined) return '—';
const numVal = typeof val === 'number' ? val : parseFloat(val);
if (isNaN(numVal)) return '—';
return ( return (
<span className={`font-medium ${val > 0 ? 'text-green-400' : 'text-red-400'}`}> <span className={`font-medium ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
{val > 0 ? '+' : ''}{val.toFixed(2)}% {numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
</span> </span>
); );
} }
@ -1034,9 +1070,11 @@ export default function OptionsFlowPanel() {
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.Pct5m || row.original.pct_5m_momo; const val = row.original.Pct5m || row.original.pct_5m_momo;
if (val === null || val === undefined) return '—'; if (val === null || val === undefined) return '—';
const numVal = typeof val === 'number' ? val : parseFloat(val);
if (isNaN(numVal)) return '—';
return ( return (
<span className={`text-xs ${val > 0 ? 'text-green-400' : 'text-red-400'}`}> <span className={`text-xs ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
{val > 0 ? '+' : ''}{val.toFixed(2)}% {numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
</span> </span>
); );
} }
@ -1048,9 +1086,11 @@ export default function OptionsFlowPanel() {
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.Pct15m || row.original.pct_15m_momo; const val = row.original.Pct15m || row.original.pct_15m_momo;
if (val === null || val === undefined) return '—'; if (val === null || val === undefined) return '—';
const numVal = typeof val === 'number' ? val : parseFloat(val);
if (isNaN(numVal)) return '—';
return ( return (
<span className={`text-xs ${val > 0 ? 'text-green-400' : 'text-red-400'}`}> <span className={`text-xs ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
{val > 0 ? '+' : ''}{val.toFixed(2)}% {numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
</span> </span>
); );
} }
@ -1198,6 +1238,118 @@ export default function OptionsFlowPanel() {
); );
} }
}, },
// Phase 2: Institutional Analytics Columns
{
accessorKey: 'confidence_score',
header: 'Confidence',
group: 'institutional',
accessorFn: (row) => row.confidence_score || 0,
cell: ({ row }) => {
const scoreRaw = row.original.confidence_score;
if (scoreRaw === null || scoreRaw === undefined) return '—';
const score = typeof scoreRaw === 'number' ? scoreRaw : parseFloat(scoreRaw);
if (isNaN(score)) return '—';
const roundedScore = Math.round(score);
const colorClass = score >= 70 ? 'text-green-400' :
score >= 50 ? 'text-yellow-400' :
'text-orange-400';
return (
<div className="flex items-center gap-2 min-w-[90px]">
<div className="flex-1 bg-slate-800 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all",
score >= 70 ? "bg-green-500" :
score >= 50 ? "bg-yellow-500" :
"bg-orange-500"
)}
style={{ width: `${Math.min(100, score)}%` }}
/>
</div>
<Badge
variant={score >= 70 ? 'success' : score >= 50 ? 'warning' : 'secondary'}
className={cn("text-xs w-12 justify-center", colorClass)}
>
{roundedScore}%
</Badge>
</div>
);
}
},
{
accessorKey: 'signal_strength',
header: 'Signal Strength',
group: 'institutional',
accessorFn: (row) => row.signal_strength || 0,
cell: ({ row }) => {
const strengthRaw = row.original.signal_strength;
if (strengthRaw === null || strengthRaw === undefined) return '—';
const strength = typeof strengthRaw === 'number' ? strengthRaw : parseFloat(strengthRaw);
if (isNaN(strength)) return '—';
const roundedStrength = Math.round(strength);
const colorClass = strength >= 70 ? 'text-purple-400' :
strength >= 50 ? 'text-blue-400' :
'text-slate-400';
return (
<div className="flex items-center gap-2 min-w-[90px]">
<div className="flex-1 bg-slate-800 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all",
strength >= 70 ? "bg-purple-500" :
strength >= 50 ? "bg-blue-500" :
"bg-slate-500"
)}
style={{ width: `${Math.min(100, strength)}%` }}
/>
</div>
<span className={cn("text-xs font-semibold w-8 text-right", colorClass)}>
{roundedStrength}
</span>
</div>
);
}
},
{
accessorKey: 'relative_premium_score',
header: 'Rel. Premium',
group: 'institutional',
accessorFn: (row) => row.relative_premium_score || 0,
cell: ({ row }) => {
const scoreRaw = row.original.relative_premium_score;
if (scoreRaw === null || scoreRaw === undefined) return '—';
const score = typeof scoreRaw === 'number' ? scoreRaw : parseFloat(scoreRaw);
if (isNaN(score)) return '—';
const roundedScore = Math.round(score);
const colorClass = score >= 70 ? 'text-green-400' :
score >= 50 ? 'text-yellow-400' :
'text-orange-400';
return (
<div className="flex items-center gap-2 min-w-[85px]">
<div className="flex-1 bg-slate-800 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all",
score >= 70 ? "bg-green-500" :
score >= 50 ? "bg-yellow-500" :
"bg-orange-500"
)}
style={{ width: `${Math.min(100, score)}%` }}
/>
</div>
<span className={cn("text-xs font-semibold w-8 text-right", colorClass)}>
{roundedScore}
</span>
</div>
);
}
},
// Phase 1: Price Reaction 5m // Phase 1: Price Reaction 5m
{ {
accessorKey: 'PriceReaction5m', accessorKey: 'PriceReaction5m',
@ -1208,11 +1360,13 @@ export default function OptionsFlowPanel() {
const ledToMove = row.original.flow_led_to_move; const ledToMove = row.original.flow_led_to_move;
if (reaction === null || reaction === undefined) return '—'; if (reaction === null || reaction === undefined) return '—';
const numReaction = typeof reaction === 'number' ? reaction : parseFloat(reaction);
if (isNaN(numReaction)) return '—';
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className={`text-xs font-medium ${reaction > 0 ? 'text-green-400' : reaction < 0 ? 'text-red-400' : 'text-slate-400'}`}> <span className={`text-xs font-medium ${numReaction > 0 ? 'text-green-400' : numReaction < 0 ? 'text-red-400' : 'text-slate-400'}`}>
{reaction > 0 ? '+' : ''}{reaction.toFixed(2)}% {numReaction > 0 ? '+' : ''}{numReaction.toFixed(2)}%
</span> </span>
{ledToMove && ( {ledToMove && (
<span className="text-xs text-green-400" title="Flow led to price move"></span> <span className="text-xs text-green-400" title="Flow led to price move"></span>
@ -1234,22 +1388,26 @@ export default function OptionsFlowPanel() {
if (!vwap) return <span className="text-xs text-slate-500">No VWAP</span>; if (!vwap) return <span className="text-xs text-slate-500">No VWAP</span>;
return '—'; return '—';
} }
const numVwapDist = typeof vwapDist === 'number' ? vwapDist : parseFloat(vwapDist);
if (isNaN(numVwapDist)) return '—';
// Color code: near VWAP = good, far from VWAP = caution // Color code: near VWAP = good, far from VWAP = caution
const absDist = Math.abs(vwapDist); const absDist = Math.abs(numVwapDist);
let colorClass = 'text-slate-400'; let colorClass = 'text-slate-400';
if (absDist <= 1) colorClass = 'text-green-400'; if (absDist <= 1) colorClass = 'text-green-400';
else if (absDist <= 2) colorClass = 'text-yellow-400'; else if (absDist <= 2) colorClass = 'text-yellow-400';
else colorClass = 'text-red-400'; else colorClass = 'text-red-400';
const numVwap = typeof vwap === 'number' ? vwap : parseFloat(vwap);
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className={`text-xs font-medium ${colorClass}`}> <span className={`text-xs font-medium ${colorClass}`}>
{vwapDist > 0 ? '+' : ''}{vwapDist.toFixed(2)}% {numVwapDist > 0 ? '+' : ''}{numVwapDist.toFixed(2)}%
</span> </span>
{vwap && ( {vwap && !isNaN(numVwap) && (
<span className="text-xs text-slate-500" title={`VWAP: $${vwap.toFixed(2)}`}> <span className="text-xs text-slate-500" title={`VWAP: $${numVwap.toFixed(2)}`}>
V: ${vwap.toFixed(2)} V: ${numVwap.toFixed(2)}
</span> </span>
)} )}
</div> </div>
@ -1541,6 +1699,29 @@ export default function OptionsFlowPanel() {
{/* View Mode Toggle & Column Selector */} {/* View Mode Toggle & Column Selector */}
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{/* Symbol Filter for Card View */}
{viewMode === 'cards' && (
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap">Symbol:</label>
<Input
type="text"
placeholder="Filter by symbol..."
value={cardViewSymbolFilter}
onChange={(e) => setCardViewSymbolFilter(e.target.value)}
className="w-32 h-8 text-xs"
/>
{cardViewSymbolFilter && (
<Button
onClick={() => setCardViewSymbolFilter('')}
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
>
</Button>
)}
</div>
)}
<div className="flex items-center gap-2 bg-slate-800/50 rounded-lg p-1"> <div className="flex items-center gap-2 bg-slate-800/50 rounded-lg p-1">
<button <button
onClick={() => setViewMode('cards')} onClick={() => setViewMode('cards')}
@ -1671,7 +1852,7 @@ export default function OptionsFlowPanel() {
{viewMode === 'cards' ? ( {viewMode === 'cards' ? (
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/50 rounded-lg shadow-lg p-4"> <div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/50 rounded-lg shadow-lg p-4">
<OptionsFlowCardList <OptionsFlowCardList
data={filteredData} data={cardViewFilteredData}
onCardClick={handleRowClick} onCardClick={handleRowClick}
selectedRow={selectedRow} selectedRow={selectedRow}
reversalsBySymbol={reversalsBySymbol} reversalsBySymbol={reversalsBySymbol}

View File

@ -0,0 +1,642 @@
import { useState, useEffect } from 'react';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3010';
function fmt(n, decimals = 2, prefix = '') {
if (n == null || isNaN(n)) return '—';
return `${prefix}${n.toFixed(decimals)}`;
}
function fmtPct(n) {
if (n == null) return '—';
const val = (n * 100).toFixed(1);
return `${val}%`;
}
function fmtLarge(n) {
if (n == null) return '—';
if (Math.abs(n) >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
if (Math.abs(n) >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
return `$${n.toFixed(0)}`;
}
function MetricRow({ label, value, highlight }) {
return (
<tr className="border-b border-slate-800/50 hover:bg-slate-800/20 transition-colors">
<td className="py-2 px-3 text-slate-400 text-sm">{label}</td>
<td className={`py-2 px-3 text-sm font-medium text-right ${
highlight === 'positive' ? 'text-emerald-400' :
highlight === 'negative' ? 'text-red-400' :
'text-white'
}`}>{value}</td>
</tr>
);
}
function SentimentBadge({ sentiment }) {
const styles = {
BULLISH: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
BEARISH: 'bg-red-500/20 text-red-400 border-red-500/30',
NEUTRAL: 'bg-slate-700/50 text-slate-400 border-slate-600',
};
const icons = { BULLISH: '▲', BEARISH: '▼', NEUTRAL: '●' };
return (
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${styles[sentiment] || styles.NEUTRAL}`}>
{icons[sentiment]} {sentiment}
</span>
);
}
function DirectionArrow({ dir }) {
if (dir === 'INCREASED') return <span className="text-emerald-400 font-bold"></span>;
if (dir === 'DECREASED') return <span className="text-red-400 font-bold"></span>;
return <span className="text-slate-500"></span>;
}
export default function StockDetailPanel({ symbol, onClose }) {
const [activeTab, setActiveTab] = useState('overview');
const [stockData, setStockData] = useState(null);
const [news, setNews] = useState([]);
const [holders, setHolders] = useState(null);
const [loading, setLoading] = useState(true);
const [newsLoading, setNewsLoading] = useState(false);
const [holdersLoading, setHoldersLoading] = useState(false);
useEffect(() => {
if (!symbol) return;
setLoading(true);
setStockData(null);
setNews([]);
setHolders(null);
fetch(`${API_BASE}/api/market/stock/${symbol}`)
.then(r => r.json())
.then(d => { if (d.success) setStockData(d.data); })
.catch(console.error)
.finally(() => setLoading(false));
}, [symbol]);
useEffect(() => {
if (!symbol || activeTab !== 'news') return;
if (news.length > 0) return;
setNewsLoading(true);
fetch(`${API_BASE}/api/market/stock/${symbol}/news`)
.then(r => r.json())
.then(d => { if (d.success) setNews(d.data || []); })
.catch(console.error)
.finally(() => setNewsLoading(false));
}, [symbol, activeTab]);
useEffect(() => {
if (!symbol || activeTab !== 'holders') return;
if (holders) return;
setHoldersLoading(true);
fetch(`${API_BASE}/api/market/stock/${symbol}/holders`)
.then(r => r.json())
.then(d => { if (d.success) setHolders(d.data); })
.catch(console.error)
.finally(() => setHoldersLoading(false));
}, [symbol, activeTab]);
if (!symbol) return null;
const q = stockData?.quote;
const f = stockData?.fundamentals;
const t = stockData?.technicals;
const meta = stockData?.meta;
const TABS = [
{ id: 'overview', label: '📋 Overview' },
{ id: 'fundamentals', label: '📊 Fundamentals' },
{ id: 'technicals', label: '📈 Technicals' },
{ id: 'holders', label: '🏛️ Institutional' },
{ id: 'news', label: '📰 News' },
];
return (
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/50 bg-slate-950/40">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">{symbol}</h2>
{stockData?.capTier && (
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
stockData.capTier === 'LARGE' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
stockData.capTier === 'MID' ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
stockData.capTier === 'ETF' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
'bg-orange-500/20 text-orange-400 border-orange-500/30'
}`}>{stockData.capTier} CAP</span>
)}
</div>
<div className="text-slate-400 text-sm">{meta?.name} · {meta?.sector}</div>
</div>
{q && (
<div className="ml-4">
<div className="text-2xl font-bold text-white tabular-nums">
${q.price?.toFixed(2) ?? '—'}
</div>
<div className={`text-sm font-medium ${
(q.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400'
}`}>
{q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) : '—'}
{' '}({q.change_pct != null ? (q.change_pct >= 0 ? '+' : '') + q.change_pct.toFixed(2) + '%' : '—'})
</div>
</div>
)}
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white transition-colors text-2xl font-light w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-800"
>
×
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-700/50 bg-slate-950/20">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-sm font-medium transition-all ${
activeTab === tab.id
? 'border-b-2 border-blue-500 text-blue-400 bg-blue-500/5'
: 'text-slate-500 hover:text-slate-300'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="p-5 overflow-y-auto max-h-[calc(100vh-300px)]">
{loading ? (
<div className="space-y-3 animate-pulse">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-10 bg-slate-800 rounded" />
))}
</div>
) : (
<>
{activeTab === 'overview' && <OverviewTab q={q} f={f} t={t} />}
{activeTab === 'fundamentals' && <FundamentalsTab f={f} />}
{activeTab === 'technicals' && <TechnicalsTab t={t} q={q} />}
{activeTab === 'holders' && <HoldersTab data={holders} loading={holdersLoading} />}
{activeTab === 'news' && <NewsTab items={news} loading={newsLoading} />}
</>
)}
</div>
</div>
);
}
function OverviewTab({ q, f, t }) {
return (
<div className="grid grid-cols-2 gap-6">
<div>
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Price Snapshot</div>
<table className="w-full">
<tbody>
<MetricRow label="Open" value={q?.open != null ? `$${q.open.toFixed(2)}` : '—'} />
<MetricRow label="Day High" value={q?.high != null ? `$${q.high.toFixed(2)}` : '—'} />
<MetricRow label="Day Low" value={q?.low != null ? `$${q.low.toFixed(2)}` : '—'} />
<MetricRow label="Prev Close" value={q?.prev_close != null ? `$${q.prev_close.toFixed(2)}` : '—'} />
<MetricRow label="52W High" value={f?.week52_high != null ? `$${f.week52_high.toFixed(2)}` : '—'} />
<MetricRow label="52W Low" value={f?.week52_low != null ? `$${f.week52_low.toFixed(2)}` : '—'} />
<MetricRow label="Volume" value={q?.volume != null ? q.volume.toLocaleString() : '—'} />
<MetricRow label="Avg Vol (3M)" value={f?.avg_volume_3m != null ? f.avg_volume_3m.toLocaleString() : '—'} />
</tbody>
</table>
</div>
<div>
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Key Metrics</div>
<table className="w-full">
<tbody>
<MetricRow label="P/E (TTM)" value={fmt(f?.pe_ttm)} />
<MetricRow label="P/E (Fwd)" value={fmt(f?.pe_forward)} />
<MetricRow label="EPS (TTM)" value={f?.eps_ttm != null ? `$${f.eps_ttm.toFixed(2)}` : '—'} />
<MetricRow label="Beta" value={fmt(f?.beta)} />
<MetricRow label="Short % Float" value={fmtPct(f?.short_pct_float)} highlight={f?.short_pct_float > 0.15 ? 'negative' : undefined} />
<MetricRow label="Inst. Ownership" value={fmtPct(f?.held_pct_institutions)} />
<MetricRow label="Analyst Target" value={f?.target_mean != null ? `$${f.target_mean.toFixed(2)}` : '—'} />
<MetricRow label="Recommendation" value={f?.recommendation?.toUpperCase() ?? '—'}
highlight={f?.recommendation === 'buy' || f?.recommendation === 'strongBuy' ? 'positive' :
f?.recommendation === 'sell' || f?.recommendation === 'strongSell' ? 'negative' : undefined}
/>
</tbody>
</table>
</div>
</div>
);
}
function FundamentalsTab({ f }) {
if (!f) return <div className="text-slate-500 text-center py-8">No fundamental data available</div>;
const sections = [
{
title: '💰 Valuation',
metrics: [
{ label: 'P/E Ratio (TTM)', value: fmt(f.pe_ttm, 1) },
{ label: 'P/E Ratio (Fwd)', value: fmt(f.pe_forward, 1) },
{ label: 'Price / Book', value: fmt(f.price_to_book, 2) },
{ label: 'Price / Sales', value: fmt(f.price_to_sales, 2) },
{ label: 'EV / EBITDA', value: fmt(f.ev_ebitda, 1) },
{ label: 'Market Cap', value: fmtLarge(f.market_cap) },
]
},
{
title: '📈 Growth',
metrics: [
{ label: 'EPS (TTM)', value: f.eps_ttm != null ? `$${f.eps_ttm.toFixed(2)}` : '—' },
{ label: 'EPS (Forward)', value: f.eps_forward != null ? `$${f.eps_forward.toFixed(2)}` : '—' },
{ label: 'Earnings Growth', value: fmtPct(f.earnings_growth), h: f.earnings_growth > 0 ? 'positive' : f.earnings_growth < 0 ? 'negative' : undefined },
{ label: 'Revenue Growth', value: fmtPct(f.revenue_growth), h: f.revenue_growth > 0 ? 'positive' : f.revenue_growth < 0 ? 'negative' : undefined },
{ label: 'Total Revenue', value: fmtLarge(f.total_revenue) },
{ label: 'Free Cash Flow', value: fmtLarge(f.free_cash_flow), h: f.free_cash_flow > 0 ? 'positive' : f.free_cash_flow < 0 ? 'negative' : undefined },
]
},
{
title: '📊 Margins',
metrics: [
{ label: 'Gross Margin', value: fmtPct(f.gross_margins) },
{ label: 'EBITDA Margin', value: fmtPct(f.ebitda_margins) },
{ label: 'Operating Margin', value: fmtPct(f.operating_margins) },
{ label: 'Profit Margin', value: fmtPct(f.profit_margins) },
{ label: 'Return on Equity', value: fmtPct(f.return_on_equity), h: f.return_on_equity > 0.15 ? 'positive' : f.return_on_equity < 0 ? 'negative' : undefined },
{ label: 'Return on Assets', value: fmtPct(f.return_on_assets) },
]
},
{
title: '🏦 Balance Sheet',
metrics: [
{ label: 'Debt / Equity', value: fmt(f.debt_to_equity, 2), h: f.debt_to_equity > 2 ? 'negative' : f.debt_to_equity < 0.5 ? 'positive' : undefined },
{ label: 'Current Ratio', value: fmt(f.current_ratio, 2), h: f.current_ratio > 2 ? 'positive' : f.current_ratio < 1 ? 'negative' : undefined },
{ label: 'Quick Ratio', value: fmt(f.quick_ratio, 2) },
{ label: 'Total Debt', value: fmtLarge(f.total_debt) },
{ label: 'Book Value / Share', value: f.book_value != null ? `$${f.book_value.toFixed(2)}` : '—' },
{ label: 'Float Shares', value: f.float_shares != null ? (f.float_shares / 1e6).toFixed(1) + 'M' : '—' },
]
},
{
title: '🎯 Analyst Consensus',
metrics: [
{ label: 'Recommendation', value: f.recommendation?.toUpperCase() ?? '—', h: f.recommendation?.includes('buy') ? 'positive' : f.recommendation?.includes('sell') ? 'negative' : undefined },
{ label: 'Target (Mean)', value: f.target_mean != null ? `$${f.target_mean.toFixed(2)}` : '—' },
{ label: 'Target (High)', value: f.target_high != null ? `$${f.target_high.toFixed(2)}` : '—' },
{ label: 'Target (Low)', value: f.target_low != null ? `$${f.target_low.toFixed(2)}` : '—' },
{ label: '# Analysts', value: f.analyst_count ?? '—' },
{ label: 'Strong Buy / Buy', value: `${f.rec_strong_buy ?? 0} / ${f.rec_buy ?? 0}`, h: 'positive' },
{ label: 'Hold', value: `${f.rec_hold ?? 0}` },
{ label: 'Sell / Strong Sell', value: `${f.rec_sell ?? 0} / ${f.rec_strong_sell ?? 0}`, h: (f.rec_sell + f.rec_strong_sell) > 0 ? 'negative' : undefined },
]
},
{
title: '📉 Short Interest',
metrics: [
{ label: 'Short % of Float', value: fmtPct(f.short_pct_float), h: f.short_pct_float > 0.20 ? 'negative' : f.short_pct_float < 0.05 ? 'positive' : undefined },
{ label: 'Shares Short', value: f.shares_short != null ? (f.shares_short / 1e6).toFixed(1) + 'M' : '—' },
{ label: 'Inst. Ownership', value: fmtPct(f.held_pct_institutions) },
{ label: 'Insider Ownership', value: fmtPct(f.held_pct_insiders) },
{ label: 'Dividend Yield', value: fmtPct(f.dividend_yield), h: f.dividend_yield > 0 ? 'positive' : undefined },
{ label: 'Payout Ratio', value: fmtPct(f.payout_ratio) },
]
},
];
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-6">
{sections.map(section => (
<div key={section.title}>
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">{section.title}</div>
<table className="w-full">
<tbody>
{section.metrics.map(m => (
<MetricRow key={m.label} label={m.label} value={m.value} highlight={m.h} />
))}
</tbody>
</table>
</div>
))}
</div>
);
}
function TechnicalsTab({ t, q }) {
if (!t) return <div className="text-slate-500 text-center py-8">Insufficient price history for technical calculations</div>;
function rsiColor(v) {
if (v == null) return 'text-white';
if (v >= 70) return 'text-red-400';
if (v <= 30) return 'text-emerald-400';
return 'text-white';
}
function rsiLabel(v) {
if (v == null) return '';
if (v >= 70) return ' (Overbought)';
if (v <= 30) return ' (Oversold)';
return ' (Neutral)';
}
return (
<div className="grid grid-cols-2 gap-6">
<div>
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Momentum Indicators</div>
<table className="w-full">
<tbody>
<tr className="border-b border-slate-800/50">
<td className="py-2 px-3 text-slate-400 text-sm">RSI (14)</td>
<td className={`py-2 px-3 text-sm font-bold text-right ${rsiColor(t.rsi_14)}`}>
{t.rsi_14 != null ? `${t.rsi_14.toFixed(1)}${rsiLabel(t.rsi_14)}` : '—'}
</td>
</tr>
<tr className="border-b border-slate-800/50">
<td className="py-2 px-3 text-slate-400 text-sm">MACD Line</td>
<td className={`py-2 px-3 text-sm font-medium text-right ${t.macd_line >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{t.macd_line != null ? t.macd_line.toFixed(4) : '—'}
</td>
</tr>
<tr className="border-b border-slate-800/50">
<td className="py-2 px-3 text-slate-400 text-sm">MACD Signal</td>
<td className="py-2 px-3 text-sm font-medium text-right text-slate-300">
{t.macd_signal != null ? t.macd_signal.toFixed(4) : '—'}
</td>
</tr>
<tr className="border-b border-slate-800/50">
<td className="py-2 px-3 text-slate-400 text-sm">MACD Histogram</td>
<td className={`py-2 px-3 text-sm font-medium text-right ${t.macd_histogram >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{t.macd_histogram != null ? t.macd_histogram.toFixed(4) : '—'}
</td>
</tr>
</tbody>
</table>
</div>
<div>
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Moving Averages</div>
<table className="w-full">
<tbody>
<tr className="border-b border-slate-800/50">
<td className="py-2 px-3 text-slate-400 text-sm">SMA 50</td>
<td className="py-2 px-3 text-sm text-right">
<span className="text-white">{t.sma_50 != null ? `$${t.sma_50.toFixed(2)}` : '—'}</span>
{t.above_sma50 != null && (
<span className={`ml-2 text-xs ${t.above_sma50 ? 'text-emerald-400' : 'text-red-400'}`}>
{t.above_sma50 ? '▲ Above' : '▼ Below'}
</span>
)}
</td>
</tr>
<tr className="border-b border-slate-800/50">
<td className="py-2 px-3 text-slate-400 text-sm">SMA 200</td>
<td className="py-2 px-3 text-sm text-right">
<span className="text-white">{t.sma_200 != null ? `$${t.sma_200.toFixed(2)}` : '—'}</span>
{t.above_sma200 != null && (
<span className={`ml-2 text-xs ${t.above_sma200 ? 'text-emerald-400' : 'text-red-400'}`}>
{t.above_sma200 ? '▲ Above' : '▼ Below'}
</span>
)}
</td>
</tr>
<tr className="border-b border-slate-800/50">
<td className="py-2 px-3 text-slate-400 text-sm">% from SMA 50</td>
<td className={`py-2 px-3 text-sm font-medium text-right ${t.pct_from_sma50 >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{t.pct_from_sma50 != null ? `${t.pct_from_sma50.toFixed(2)}%` : '—'}
</td>
</tr>
<tr className="border-b border-slate-800/50">
<td className="py-2 px-3 text-slate-400 text-sm">% from SMA 200</td>
<td className={`py-2 px-3 text-sm font-medium text-right ${t.pct_from_sma200 >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{t.pct_from_sma200 != null ? `${t.pct_from_sma200.toFixed(2)}%` : '—'}
</td>
</tr>
</tbody>
</table>
<div className="mt-4 p-3 rounded-lg bg-slate-800/50 border border-slate-700/30">
<div className="text-xs text-slate-500 mb-1 font-semibold uppercase tracking-wide">Signal Summary</div>
<div className="flex flex-wrap gap-2 mt-1">
{t.rsi_14 != null && (
<span className={`text-xs px-2 py-1 rounded border ${
t.rsi_14 >= 70 ? 'bg-red-500/20 text-red-400 border-red-500/30' :
t.rsi_14 <= 30 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
'bg-slate-700 text-slate-400 border-slate-600'
}`}>
RSI {t.rsi_14 >= 70 ? '🔴 OS' : t.rsi_14 <= 30 ? '🟢 OB' : '⚪ Neutral'}
</span>
)}
{t.macd_histogram != null && (
<span className={`text-xs px-2 py-1 rounded border ${
t.macd_histogram > 0 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
MACD {t.macd_histogram > 0 ? '🟢 Bull' : '🔴 Bear'}
</span>
)}
{t.above_sma50 != null && (
<span className={`text-xs px-2 py-1 rounded border ${
t.above_sma50 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{t.above_sma50 ? '🟢 Above SMA50' : '🔴 Below SMA50'}
</span>
)}
{t.above_sma200 != null && (
<span className={`text-xs px-2 py-1 rounded border ${
t.above_sma200 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{t.above_sma200 ? '🟢 Above SMA200' : '🔴 Below SMA200'}
</span>
)}
</div>
</div>
</div>
</div>
);
}
function HoldersTab({ data, loading }) {
if (loading) return <div className="space-y-3 animate-pulse">{Array.from({ length: 5 }).map((_, i) => <div key={i} className="h-10 bg-slate-800 rounded" />)}</div>;
if (!data) return <div className="text-slate-500 text-center py-8">Loading holder data...</div>;
const inst = data.institutional;
const ins = data.insiders;
function fmtLargeLocal(n) {
if (n == null) return '—';
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
if (Math.abs(n) >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
return `$${n.toFixed(0)}`;
}
return (
<div className="space-y-6">
{/* Institutional summary */}
{inst?.summary && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Inst. Ownership', value: inst.summary.pct_held_institutions != null ? `${(inst.summary.pct_held_institutions * 100).toFixed(1)}%` : '—' },
{ label: 'Float Inst. %', value: inst.summary.pct_float_institutions != null ? `${(inst.summary.pct_float_institutions * 100).toFixed(1)}%` : '—' },
{ label: 'Insider %', value: inst.summary.pct_held_insiders != null ? `${(inst.summary.pct_held_insiders * 100).toFixed(1)}%` : '—' },
{ label: 'Inst. Count', value: inst.summary.institution_count?.toLocaleString() ?? '—' },
].map(m => (
<div key={m.label} className="bg-slate-800/50 rounded-lg p-3 border border-slate-700/30">
<div className="text-xs text-slate-500">{m.label}</div>
<div className="text-lg font-bold text-white mt-1">{m.value}</div>
</div>
))}
</div>
)}
{/* Top institutional holders */}
{inst?.holders && inst.holders.length > 0 && (
<div>
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">🏛 Top Institutional Holders</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-slate-500 text-xs bg-slate-950/30">
<th className="text-left px-3 py-2">Institution</th>
<th className="text-right px-3 py-2">% Held</th>
<th className="text-right px-3 py-2">Shares</th>
<th className="text-right px-3 py-2">Value</th>
<th className="text-center px-3 py-2">QoQ Change</th>
<th className="text-right px-3 py-2">Reported</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{inst.holders.slice(0, 10).map((h, i) => (
<tr key={i} className="hover:bg-slate-800/20">
<td className="px-3 py-2.5 text-white font-medium">{h.name}</td>
<td className="px-3 py-2.5 text-right text-slate-300 tabular-nums">
{h.pct_held != null ? `${(h.pct_held * 100).toFixed(2)}%` : '—'}
</td>
<td className="px-3 py-2.5 text-right text-slate-400 tabular-nums">
{h.shares != null ? h.shares.toLocaleString() : '—'}
</td>
<td className="px-3 py-2.5 text-right text-slate-400 tabular-nums">
{h.value != null ? fmtLargeLocal(h.value) : '—'}
</td>
<td className="px-3 py-2.5 text-center">
<DirectionArrow dir={h.direction} />
{h.shares_change != null && (
<span className={`ml-1 text-xs ${h.shares_change > 0 ? 'text-emerald-400' : h.shares_change < 0 ? 'text-red-400' : 'text-slate-500'}`}>
{h.shares_change > 0 ? '+' : ''}{h.shares_change?.toLocaleString()}
</span>
)}
</td>
<td className="px-3 py-2.5 text-right text-slate-500 text-xs">{h.date_reported || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Insider activity */}
{ins && (
<div>
<div className="flex items-center gap-3 mb-3">
<div className="text-xs uppercase tracking-widest text-slate-500 font-semibold">👤 Insider Activity (90d)</div>
{ins.insider_summary && (
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
ins.insider_summary.net_sentiment === 'BULLISH' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
ins.insider_summary.net_sentiment === 'BEARISH' ? 'bg-red-500/20 text-red-400 border-red-500/30' :
'bg-slate-700 text-slate-400 border-slate-600'
}`}>
{ins.insider_summary.net_sentiment} · {ins.insider_summary.buys_90d}B / {ins.insider_summary.sells_90d}S
</span>
)}
</div>
{ins.transactions && ins.transactions.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-slate-500 text-xs bg-slate-950/30">
<th className="text-left px-3 py-2">Insider</th>
<th className="text-left px-3 py-2">Title</th>
<th className="text-center px-3 py-2">Type</th>
<th className="text-right px-3 py-2">Shares</th>
<th className="text-right px-3 py-2">Value</th>
<th className="text-right px-3 py-2">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{ins.transactions.slice(0, 15).map((tx, i) => (
<tr key={i} className="hover:bg-slate-800/20">
<td className="px-3 py-2 text-white font-medium">{tx.insider_name}</td>
<td className="px-3 py-2 text-slate-400 text-xs">{tx.title}</td>
<td className="px-3 py-2 text-center">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
tx.direction === 'BUY' ? 'bg-emerald-500/20 text-emerald-400' :
tx.direction === 'SELL' ? 'bg-red-500/20 text-red-400' :
'bg-slate-700 text-slate-400'
}`}>{tx.direction}</span>
</td>
<td className="px-3 py-2 text-right text-slate-300 tabular-nums">
{tx.shares != null ? tx.shares.toLocaleString() : '—'}
</td>
<td className="px-3 py-2 text-right text-slate-400 tabular-nums">
{tx.value != null ? fmtLargeLocal(tx.value) : '—'}
</td>
<td className="px-3 py-2 text-right text-slate-500 text-xs">{tx.start_date || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-slate-500 text-sm py-4 text-center">No recent insider transactions</div>
)}
</div>
)}
</div>
);
}
function NewsTab({ items, loading }) {
if (loading) return <div className="space-y-3 animate-pulse">{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-16 bg-slate-800 rounded" />)}</div>;
if (!items || items.length === 0) return <div className="text-slate-500 text-center py-8">No financial news found for this symbol</div>;
return (
<div className="space-y-3">
{items.map((item, i) => (
<a
key={i}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-lg bg-slate-800/40 border border-slate-700/30 hover:bg-slate-800/70 hover:border-slate-600/50 transition-all group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium group-hover:text-blue-400 transition-colors line-clamp-2">
{item.title}
</div>
{item.snippet && (
<div className="text-slate-500 text-xs mt-1 line-clamp-1">{item.snippet}</div>
)}
<div className="flex items-center gap-2 mt-1.5">
<span className="text-xs text-slate-600">{item.source}</span>
{item.publishedAt && (
<>
<span className="text-xs text-slate-700">·</span>
<span className="text-xs text-slate-600">
{new Date(item.publishedAt).toLocaleDateString()}
</span>
</>
)}
</div>
</div>
<SentimentBadge sentiment={item.sentiment} />
</div>
</a>
))}
</div>
);
}

View File

@ -1,25 +1,25 @@
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '../ui/Badge'; import { Badge } from '../ui/Badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useAIAnalysis } from '@/hooks/useAIAnalysis';
import { Phase1EligibilityModal } from './Phase1EligibilityModal'; import { Phase1EligibilityModal } from './Phase1EligibilityModal';
import { CheckCircle2 } from 'lucide-react'; import { FlowDetailsSection } from './expandedRowDetails/FlowDetailsSection';
import { InstitutionalAnalyticsSection } from './expandedRowDetails/InstitutionalAnalyticsSection';
import { DealerRegimeSection } from './expandedRowDetails/DealerRegimeSection';
import { TimeSequencedSection } from './expandedRowDetails/TimeSequencedSection';
import { PriceContextSection } from './expandedRowDetails/PriceContextSection';
import { AnalysisSection } from './expandedRowDetails/AnalysisSection';
import { VolumeHistorySection } from './expandedRowDetails/VolumeHistorySection';
export function ExpandedRowDetails({ row }) { export function ExpandedRowDetails({ row }) {
const [showAIAnalysis, setShowAIAnalysis] = useState(false);
const [showPhase1Modal, setShowPhase1Modal] = useState(false); const [showPhase1Modal, setShowPhase1Modal] = useState(false);
const { loading: aiLoading, error: aiError, analysis: aiAnalysis, fetchAnalysis } = useAIAnalysis();
// Check if Phase 1 data exists // Check if Phase 1 data exists
const hasPhase1Data = row.signal_tier !== undefined || row.checklist_passed !== undefined || row.flow_led_to_move !== undefined; const hasPhase1Data = row.signal_tier !== undefined || row.checklist_passed !== undefined || row.flow_led_to_move !== undefined;
const signal = row.tradeSignal; const signal = row.tradeSignal;
const score = row.rocketScore || row.rocket_score || 0;
const netPremium = (row.bull_total || 0) - (row.bear_total || 0); const netPremium = (row.bull_total || 0) - (row.bear_total || 0);
const bullTotal = row.bull_total || 0; const bullTotal = row.bull_total || 0;
const bearTotal = row.bear_total || 0; const bearTotal = row.bear_total || 0;
const premium = row.premium_num || 0;
const volume = row.Volume || row.vol_num || 0; const volume = row.Volume || row.vol_num || 0;
const oi = row.OI || row.oi_num || 0; const oi = row.OI || row.oi_num || 0;
const currentPriceRaw = row.u_close || row.spot_num || row.Price || 0; const currentPriceRaw = row.u_close || row.spot_num || row.Price || 0;
@ -42,8 +42,6 @@ export function ExpandedRowDetails({ row }) {
const expirationDate = row.ExpirationDate; const expirationDate = row.ExpirationDate;
// Additional important details // Additional important details
const side = row.Side || '—';
const price = row.Price || 0;
const spot = row.Spot || row.u_close || row.spot_num || 0; const spot = row.Spot || row.u_close || row.spot_num || 0;
const moneynessPct = row.moneyness_pct || 0; const moneynessPct = row.moneyness_pct || 0;
const lastFlowMinutesAgo = trend?.lastFlowMinutesAgo ?? decay?.lastFlowMinutesAgo; const lastFlowMinutesAgo = trend?.lastFlowMinutesAgo ?? decay?.lastFlowMinutesAgo;
@ -106,9 +104,8 @@ export function ExpandedRowDetails({ row }) {
const formatExpirationDate = (date) => { const formatExpirationDate = (date) => {
if (!date) return 'N/A'; if (!date) return 'N/A';
try { try {
// Handle both ISO format and M/D/YYYY format
if (typeof date === 'string' && date.includes('/')) { if (typeof date === 'string' && date.includes('/')) {
return date; // Already in M/D/YYYY format return date;
} }
return new Date(date).toLocaleDateString('en-US', { return new Date(date).toLocaleDateString('en-US', {
month: 'long', month: 'long',
@ -145,211 +142,46 @@ export function ExpandedRowDetails({ row }) {
return ( return (
<div className="bg-slate-800/30 border-t border-slate-700 p-6"> <div className="bg-slate-800/30 border-t border-slate-700 p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Flow Details */} {/* Flow Details */}
<div className="space-y-3"> <FlowDetailsSection
<h4 className="text-sm font-semibold text-slate-300 mb-3">🔹 FLOW DETAILS</h4> row={row}
flowTrend={flowTrend}
{/* Flow Trend Visual */} lastFlowMinutesAgo={lastFlowMinutesAgo}
{flowTrend && ( hoursAgo={hoursAgo}
<div className={cn( netPremium={netPremium}
"mb-4 p-4 border-2 rounded-lg", bullTotal={bullTotal}
flowTrend.color === 'red' && flowTrend.label === 'DEAD' bearTotal={bearTotal}
? "bg-red-900/30 border-red-500" volume={volume}
: flowTrend.color === 'red' && flowTrend.label === 'SURGING' oi={oi}
? "bg-red-900/30 border-red-500" />
: flowTrend.color === 'yellow'
? "bg-yellow-900/30 border-yellow-500"
: "bg-orange-900/30 border-orange-500"
)}>
<div className="flex items-center gap-3">
<span className="text-5xl">{flowTrend.icon}</span>
<div>
<div className={cn(
"text-2xl font-bold",
flowTrend.color === 'red' ? "text-red-400" :
flowTrend.color === 'yellow' ? "text-yellow-400" :
"text-orange-400"
)}>
{flowTrend.label} FLOW
</div>
<div className="text-sm text-gray-300">
Last activity: <span className={cn(
"font-bold",
flowTrend.color === 'red' ? "text-red-300" :
flowTrend.color === 'yellow' ? "text-yellow-300" :
"text-orange-300"
)}>
{lastFlowMinutesAgo} minutes ago {hoursAgo > 0 && `(${hoursAgo} ${hoursAgo === 1 ? 'hour' : 'hours'})`}
</span>
</div>
<div className={cn(
"text-sm mt-1",
flowTrend.color === 'red' ? "text-yellow-300" :
flowTrend.color === 'yellow' ? "text-yellow-200" :
"text-orange-200"
)}>
{flowTrend.message}
</div>
</div>
</div>
</div>
)}
<div className="space-y-2 text-xs"> {/* Institutional Analytics - Phase 1 */}
<div> <InstitutionalAnalyticsSection row={row} />
<span className="text-slate-500">Net Premium:</span>
<span className={cn( {/* Phase 3: Advanced Dealer & Regime Analytics */}
"ml-2 font-semibold", <DealerRegimeSection row={row} />
netPremium > 0 ? "text-green-400" : "text-red-400"
)}> {/* Time-Sequenced Metrics - Separate Section */}
${(Math.abs(netPremium) / 1000000).toFixed(2)}M <TimeSequencedSection row={row} />
{netPremium > 0 ? ` (${((netPremium / (bullTotal + bearTotal)) * 100).toFixed(0)}% Bullish)` : ` (${((Math.abs(netPremium) / (bullTotal + bearTotal)) * 100).toFixed(0)}% Bearish)`}
</span>
</div>
<div>
<span className="text-slate-500">Premium Breakdown:</span>
<div className="ml-2 mt-1">
<div className="text-green-400">${(bullTotal / 1000000).toFixed(2)}M Calls</div>
<div className="text-red-400">${(bearTotal / 1000000).toFixed(2)}M Puts</div>
</div>
</div>
<div>
<span className="text-slate-500">Flow Type:</span>
<span className="ml-2 text-slate-300">
{row.cp_norm === 'CALL' ? 'ITM Calls' : 'ITM Puts'}
{row.badgesRaw?.more?.includes('💎') ? ' (💎 Real Money)' : ' (Speculation)'}
</span>
</div>
<div>
<span className="text-slate-500">Volume:</span>
<span className="ml-2 text-slate-300">{volume.toLocaleString()} contracts</span>
</div>
{oi > 0 ? (
<div>
<span className="text-slate-500">Open Interest:</span>
<span className="ml-2 text-slate-300">
{oi.toLocaleString()} OI {oi >= 12500 ? '(High)' : oi >= 5000 ? '(Medium)' : '(Low)'}
</span>
</div>
) : (
<div>
<span className="text-slate-500">Open Interest:</span>
<span className="ml-2 text-slate-300 text-slate-400 italic">New flow (no OI yet)</span>
</div>
)}
</div>
</div>
{/* Price Context */} {/* Price Context */}
<div className="space-y-3"> <PriceContextSection
<h4 className="text-sm font-semibold text-slate-300 mb-3">📊 PRICE CONTEXT</h4> row={row}
<div className="space-y-2 text-xs"> currentPrice={currentPrice}
<div> vwap={vwap}
<span className="text-slate-500">Current:</span> rthOpen={rthOpen}
<span className="ml-2 text-slate-200 font-semibold"> pctVsRthOpen={pctVsRthOpen}
${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'} pctVsPriorClose={pctVsPriorClose}
</span> spot={spot}
</div> moneynessPct={moneynessPct}
<div> session={session}
<span className="text-slate-500">VWAP:</span> tapeAlign={tapeAlign}
<span className="ml-2 text-slate-200"> />
${typeof vwap === 'number' ? vwap.toFixed(2) : 'N/A'}
</span>
</div>
<div>
<span className="text-slate-500">RTH Open:</span>
<span className="ml-2 text-slate-200">
${typeof rthOpen === 'number' ? rthOpen.toFixed(2) : 'N/A'}
</span>
</div>
{Math.abs(pctVsRthOpen) < 0.01 && Math.abs(pctVsPriorClose) < 0.01 ? (
<div>
<span className="text-slate-500">Price Change:</span>
<span className="ml-2 text-slate-300 font-semibold">0.00% (flat since open)</span>
</div>
) : (
<>
<div>
<span className="text-slate-500">vs RTH Open:</span>
<span className={cn(
"ml-2 font-semibold",
pctVsRthOpen > 0 ? "text-green-400" : "text-red-400"
)}>
{pctVsRthOpen > 0 ? '+' : ''}{typeof pctVsRthOpen === 'number' ? pctVsRthOpen.toFixed(2) : '0.00'}%
</span>
</div>
<div>
<span className="text-slate-500">vs Prior Close:</span>
<span className={cn(
"ml-2 font-semibold",
pctVsPriorClose > 0 ? "text-green-400" : "text-red-400"
)}>
{pctVsPriorClose > 0 ? '+' : ''}{typeof pctVsPriorClose === 'number' ? pctVsPriorClose.toFixed(2) : '0.00'}%
</span>
</div>
</>
)}
<div>
<span className="text-slate-500">Spot Price:</span>
<span className="ml-2 text-slate-200 font-semibold">
${typeof spot === 'number' ? spot.toFixed(2) : spot || 'N/A'}
</span>
</div>
<div>
<span className="text-slate-500">Moneyness:</span>
<span className={cn(
"ml-2 font-semibold",
moneynessPct > 0 ? "text-green-400" : moneynessPct < 0 ? "text-red-400" : "text-slate-300"
)}>
{typeof moneynessPct === 'number' ? moneynessPct.toFixed(2) : moneynessPct || '0.00'}%
</span>
</div>
<div>
<span className="text-slate-500">Session:</span>
<span className="ml-2 text-slate-300">
<Badge variant="outline" className="text-xs">
{session}
</Badge>
</span>
</div>
</div>
{/* Tape Alignment Visual */}
{tapeAlign !== '—' ? (
<div className="mt-4 p-4 bg-green-900/30 border-2 border-green-500 rounded-lg">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<div>
<div className="text-xl font-bold text-green-400">TAPE ALIGNED</div>
<div className="text-sm text-gray-300">Price confirming flow direction </div>
</div>
</div>
</div>
) : (
<div className="mt-4 p-4 bg-orange-900/30 border-2 border-orange-500 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<div>
<div className="text-xl font-bold text-orange-400">NO TAPE ALIGNMENT</div>
<div className="text-sm text-gray-300">Price not confirming flow direction</div>
</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-400">Current Price</div>
<div className="text-2xl font-bold text-orange-300">${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}</div>
<div className="text-xs text-gray-400">Expected: Upward momentum</div>
<div className="text-xs text-red-300">Reality: Flat/down</div>
</div>
</div>
</div>
)}
</div>
{/* Timing & Dates */} {/* Timing & Dates */}
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-semibold text-slate-300 mb-3">📅 TIMING & DATES</h4> <h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">📅 TIMING & DATES</h4>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
<div> <div>
<span className="text-slate-500">Created Date:</span> <span className="text-slate-500">Created Date:</span>
@ -387,7 +219,7 @@ export function ExpandedRowDetails({ row }) {
{/* Catalyst */} {/* Catalyst */}
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-semibold text-slate-300 mb-3"> CATALYST</h4> <h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2"> CATALYST</h4>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
{catalyst !== 'None' ? ( {catalyst !== 'None' ? (
<> <>
@ -490,183 +322,14 @@ export function ExpandedRowDetails({ row }) {
)} )}
{/* Phase 1 & AI Analysis */} {/* Phase 1 & AI Analysis */}
<div className="space-y-3 md:col-span-2 lg:col-span-3"> <AnalysisSection
<div className="flex items-center justify-between mb-3 gap-2"> row={row}
<h4 className="text-sm font-semibold text-slate-300">🔍 ANALYSIS</h4> hasPhase1Data={hasPhase1Data}
<div className="flex gap-2"> onPhase1Click={() => setShowPhase1Modal(true)}
{/* Phase 1 Eligibility Check */} />
<Button
variant="outline"
size="sm"
onClick={() => setShowPhase1Modal(true)}
className={cn(
"text-xs",
hasPhase1Data
? "bg-blue-500/20 border-blue-500/50 text-blue-400 hover:bg-blue-500/30"
: "bg-slate-700/50 border-slate-600 text-slate-400"
)}
title={hasPhase1Data ? "Check Phase 1 Eligibility" : "Phase 1 data not available"}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Phase 1
</Button>
{/* AI Analysis */}
{!showAIAnalysis && (
<Button
variant="outline"
size="sm"
onClick={() => {
setShowAIAnalysis(true);
fetchAnalysis(row);
}}
disabled={aiLoading}
className="text-xs"
>
{aiLoading ? 'Analyzing...' : 'AI Analysis'}
</Button>
)}
</div>
</div>
{showAIAnalysis && (
<div className="bg-slate-800/50 rounded-lg p-4 space-y-3">
{aiLoading && (
<div className="text-sm text-slate-400">Analyzing with Claude AI...</div>
)}
{aiError && (
<div className="text-sm text-red-400">Error: {aiError}</div>
)}
{aiAnalysis && aiAnalysis.analysis && (
<div className="space-y-3">
<div>
<div className="flex items-center gap-2 mb-2">
<Badge
variant={
aiAnalysis.analysis.assessment === 'BULLISH' ? 'success' :
aiAnalysis.analysis.assessment === 'BEARISH' ? 'destructive' : 'secondary'
}
className="text-xs"
>
{aiAnalysis.analysis.assessment}
</Badge>
<Badge
variant={
aiAnalysis.analysis.recommendation === 'ENTER' ? 'success' :
aiAnalysis.analysis.recommendation === 'WAIT' ? 'warning' : 'destructive'
}
className="text-xs"
>
{aiAnalysis.analysis.recommendation}
</Badge>
<Badge variant="outline" className="text-xs">
{aiAnalysis.analysis.riskLevel} Risk
</Badge>
</div>
<p className="text-sm text-slate-200">{aiAnalysis.analysis.summary}</p>
</div>
{aiAnalysis.analysis.keyFactors && aiAnalysis.analysis.keyFactors.length > 0 && (
<div>
<div className="text-xs text-slate-500 mb-1">Key Factors:</div>
<ul className="text-xs text-slate-300 space-y-1">
{aiAnalysis.analysis.keyFactors.map((factor, idx) => (
<li key={idx}> {factor}</li>
))}
</ul>
</div>
)}
{aiAnalysis.analysis.reasoning && (
<div>
<div className="text-xs text-slate-500 mb-1">Reasoning:</div>
<p className="text-xs text-slate-300">{aiAnalysis.analysis.reasoning}</p>
</div>
)}
<div className="flex items-center gap-4 text-xs text-slate-400 pt-2 border-t border-slate-700">
<span>Time Horizon: {aiAnalysis.analysis.timeHorizon}</span>
{aiAnalysis.timestamp && (
<span> {new Date(aiAnalysis.timestamp).toLocaleTimeString()}</span>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* Last 5 Days Volume History - Table Format */} {/* Last 5 Days Volume History - Table Format */}
{row.volumeHistory && row.volumeHistory.length > 0 && ( <VolumeHistorySection row={row} />
<div className="space-y-3 md:col-span-2 lg:col-span-3 mt-6 pt-6 border-t border-slate-700/50">
<h4 className="text-sm font-semibold text-slate-300 mb-3">📊 LAST 5 DAYS VOLUME</h4>
<div className="bg-slate-800/50 rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-700/50 bg-slate-800/70">
<th className="text-left py-3 px-4 text-slate-400 font-semibold">Date</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Volume</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Open</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">High</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Low</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Close</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">VWAP</th>
</tr>
</thead>
<tbody>
{row.volumeHistory.map((day, idx) => {
const date = new Date(day.date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dayDate = new Date(date);
dayDate.setHours(0, 0, 0, 0);
let dayLabel;
if (dayDate.getTime() === today.getTime()) {
dayLabel = 'Today';
} else if (dayDate.getTime() === yesterday.getTime()) {
dayLabel = 'Yesterday';
} else {
dayLabel = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// Format volume with K/M/B
const formatVolume = (vol) => {
if (!vol || vol === 0) return '—';
const absVal = Math.abs(vol);
if (absVal >= 1e9) return `${(absVal / 1e9).toFixed(2)}B`;
if (absVal >= 1e6) return `${(absVal / 1e6).toFixed(2)}M`;
if (absVal >= 1e3) return `${(absVal / 1e3).toFixed(2)}K`;
return absVal.toLocaleString();
};
// Format price
const formatPrice = (price) => {
if (!price || price === 0) return '—';
return `$${parseFloat(price).toFixed(2)}`;
};
return (
<tr key={idx} className="border-b border-slate-700/30 hover:bg-slate-800/70 transition-colors">
<td className="py-3 px-4 text-slate-300 font-medium">{dayLabel}</td>
<td className="py-3 px-4 text-right text-slate-200 font-mono">{formatVolume(day.volume)}</td>
<td className="py-3 px-4 text-right text-slate-300 font-mono">{formatPrice(day.open)}</td>
<td className="py-3 px-4 text-right text-green-400 font-mono">{formatPrice(day.high)}</td>
<td className="py-3 px-4 text-right text-red-400 font-mono">{formatPrice(day.low)}</td>
<td className="py-3 px-4 text-right text-slate-200 font-mono font-semibold">{formatPrice(day.close)}</td>
<td className="py-3 px-4 text-right text-blue-400 font-mono font-semibold">{formatPrice(day.vwap)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div> </div>
{/* Phase 1 Eligibility Modal */} {/* Phase 1 Eligibility Modal */}
@ -678,4 +341,3 @@ export function ExpandedRowDetails({ row }) {
</div> </div>
); );
} }

View File

@ -0,0 +1,122 @@
import { useState } from 'react';
import { Badge } from '../../ui/Badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { useAIAnalysis } from '@/hooks/useAIAnalysis';
import { CheckCircle2 } from 'lucide-react';
export function AnalysisSection({ row, hasPhase1Data, onPhase1Click }) {
const [showAIAnalysis, setShowAIAnalysis] = useState(false);
const { loading: aiLoading, error: aiError, analysis: aiAnalysis, fetchAnalysis } = useAIAnalysis();
return (
<div className="space-y-3 md:col-span-2 lg:col-span-3">
<div className="flex items-center justify-between mb-3 gap-2">
<h4 className="text-sm font-semibold text-slate-300">🔍 ANALYSIS</h4>
<div className="flex gap-2">
{/* Phase 1 Eligibility Check */}
<Button
variant="outline"
size="sm"
onClick={onPhase1Click}
className={cn(
"text-xs",
hasPhase1Data
? "bg-blue-500/20 border-blue-500/50 text-blue-400 hover:bg-blue-500/30"
: "bg-slate-700/50 border-slate-600 text-slate-400"
)}
title={hasPhase1Data ? "Check Phase 1 Eligibility" : "Phase 1 data not available"}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Phase 1
</Button>
{/* AI Analysis */}
{!showAIAnalysis && (
<Button
variant="outline"
size="sm"
onClick={() => {
setShowAIAnalysis(true);
fetchAnalysis(row);
}}
disabled={aiLoading}
className="text-xs"
>
{aiLoading ? 'Analyzing...' : 'AI Analysis'}
</Button>
)}
</div>
</div>
{showAIAnalysis && (
<div className="bg-slate-800/50 rounded-lg p-4 space-y-3">
{aiLoading && (
<div className="text-sm text-slate-400">Analyzing with Claude AI...</div>
)}
{aiError && (
<div className="text-sm text-red-400">Error: {aiError}</div>
)}
{aiAnalysis && aiAnalysis.analysis && (
<div className="space-y-3">
<div>
<div className="flex items-center gap-2 mb-2">
<Badge
variant={
aiAnalysis.analysis.assessment === 'BULLISH' ? 'success' :
aiAnalysis.analysis.assessment === 'BEARISH' ? 'destructive' : 'secondary'
}
className="text-xs"
>
{aiAnalysis.analysis.assessment}
</Badge>
<Badge
variant={
aiAnalysis.analysis.recommendation === 'ENTER' ? 'success' :
aiAnalysis.analysis.recommendation === 'WAIT' ? 'warning' : 'destructive'
}
className="text-xs"
>
{aiAnalysis.analysis.recommendation}
</Badge>
<Badge variant="outline" className="text-xs">
{aiAnalysis.analysis.riskLevel} Risk
</Badge>
</div>
<p className="text-sm text-slate-200">{aiAnalysis.analysis.summary}</p>
</div>
{aiAnalysis.analysis.keyFactors && aiAnalysis.analysis.keyFactors.length > 0 && (
<div>
<div className="text-xs text-slate-500 mb-1">Key Factors:</div>
<ul className="text-xs text-slate-300 space-y-1">
{aiAnalysis.analysis.keyFactors.map((factor, idx) => (
<li key={idx}> {factor}</li>
))}
</ul>
</div>
)}
{aiAnalysis.analysis.reasoning && (
<div>
<div className="text-xs text-slate-500 mb-1">Reasoning:</div>
<p className="text-xs text-slate-300">{aiAnalysis.analysis.reasoning}</p>
</div>
)}
<div className="flex items-center gap-4 text-xs text-slate-400 pt-2 border-t border-slate-700">
<span>Time Horizon: {aiAnalysis.analysis.timeHorizon}</span>
{aiAnalysis.timestamp && (
<span> {new Date(aiAnalysis.timestamp).toLocaleTimeString()}</span>
)}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,200 @@
import { cn } from '@/utils/cn';
export function DealerRegimeSection({ row }) {
const hasDealerData = (row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined) ||
row.market_regime || row.volatility_intent;
if (!hasDealerData) return null;
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">🎯 DEALER & REGIME ANALYSIS</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{/* Dealer Hedge Pressure */}
{(row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined) && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-xs text-slate-500 mb-2">Dealer Hedge Pressure</div>
<div className="flex items-center gap-3">
<div className="text-3xl font-bold text-orange-400">
{Math.round(row.dealer_hedge_pressure_score)}
</div>
{row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined && (
<div className="flex-1">
<div className="w-full bg-slate-700 rounded-full h-2 mb-1">
<div
className={cn(
"h-2 rounded-full transition-all",
row.dealer_hedge_pressure_score >= 70 ? "bg-red-500" :
row.dealer_hedge_pressure_score >= 50 ? "bg-orange-500" :
"bg-yellow-500"
)}
style={{ width: `${Math.min(100, row.dealer_hedge_pressure_score)}%` }}
/>
</div>
<div className="text-xs text-slate-400">
{row.dealer_hedge_pressure_score >= 70 ? "High Pressure" :
row.dealer_hedge_pressure_score >= 50 ? "Moderate" : "Low"}
</div>
</div>
)}
</div>
{row.net_gamma_exposure_per_symbol !== null && row.net_gamma_exposure_per_symbol !== undefined && (
<div className="mt-2 text-xs text-slate-500">
Net Gamma: {row.net_gamma_exposure_per_symbol >= 1e9
? `${(row.net_gamma_exposure_per_symbol / 1e9).toFixed(2)}B`
: row.net_gamma_exposure_per_symbol >= 1e6
? `${(row.net_gamma_exposure_per_symbol / 1e6).toFixed(1)}M`
: `${(row.net_gamma_exposure_per_symbol / 1e3).toFixed(0)}K`}
</div>
)}
</div>
)}
{/* Market Regime */}
{row.market_regime && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-xs text-slate-500 mb-2">Market Regime</div>
<div className="flex flex-col gap-2">
{row.market_regime === 'TREND' && (
<div className="flex items-center gap-2">
<span className="text-2xl">📈</span>
<div>
<div className="text-xl font-bold text-green-400">TREND</div>
<div className="text-xs text-slate-400">Continuation bias</div>
</div>
</div>
)}
{row.market_regime === 'RANGE' && (
<div className="flex items-center gap-2">
<span className="text-2xl"></span>
<div>
<div className="text-xl font-bold text-yellow-400">RANGE</div>
<div className="text-xs text-slate-400">Fade or vol-sell bias</div>
</div>
</div>
)}
{row.market_regime === 'HIGH_VOL_EVENT' && (
<div className="flex items-center gap-2">
<span className="text-2xl"></span>
<div>
<div className="text-xl font-bold text-red-400">HIGH VOL EVENT</div>
<div className="text-xs text-slate-400">Volatility expansion bias</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Volatility Intent */}
{row.volatility_intent && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-xs text-slate-500 mb-2">Volatility Intent</div>
<div className="flex flex-col gap-2">
{row.volatility_intent === 'LONG_VOL' && (
<div className="flex items-center gap-2">
<span className="text-2xl">📊</span>
<div>
<div className="text-lg font-bold text-green-400">LONG VOL</div>
<div className="text-xs text-slate-400">Buying volatility</div>
</div>
</div>
)}
{row.volatility_intent === 'SHORT_VOL' && (
<div className="flex items-center gap-2">
<span className="text-2xl">📉</span>
<div>
<div className="text-lg font-bold text-red-400">SHORT VOL</div>
<div className="text-xs text-slate-400">Selling volatility</div>
</div>
</div>
)}
{row.volatility_intent === 'DIRECTIONAL' && (
<div className="flex items-center gap-2">
<span className="text-2xl"></span>
<div>
<div className="text-lg font-bold text-blue-400">DIRECTIONAL</div>
<div className="text-xs text-slate-400">Directional positioning</div>
</div>
</div>
)}
{row.volatility_intent === 'HEDGE_UNWIND' && (
<div className="flex items-center gap-2">
<span className="text-2xl">🔄</span>
<div>
<div className="text-lg font-bold text-orange-400">HEDGE/UNWIND</div>
<div className="text-xs text-slate-400">Hedging or unwinding</div>
</div>
</div>
)}
</div>
{row.delta_exposure !== null && row.delta_exposure !== undefined && (
<div className="mt-2 text-xs text-slate-500">
Delta Exp: {Math.abs(row.delta_exposure) >= 1e6
? `${(row.delta_exposure / 1e6).toFixed(1)}M`
: `${(row.delta_exposure / 1e3).toFixed(0)}K`}
</div>
)}
</div>
)}
</div>
{/* Additional Dealer Metrics */}
{(row.gamma_flip_proximity !== null || row.gamma_exposure !== null ||
row.dealer_pain_level !== null || row.flow_state) && (
<div className="mt-4 pt-3 border-t border-slate-700 grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
{row.gamma_flip_proximity !== null && row.gamma_flip_proximity !== undefined && (
<div>
<span className="text-slate-500">Gamma Flip Proximity:</span>
<span className="ml-2 text-slate-300 font-semibold">
{row.gamma_flip_proximity.toFixed(2)}
</span>
<div className="text-xs text-slate-500 mt-0.5">
{row.gamma_flip_proximity > 0.5 ? "Long gamma zone" :
row.gamma_flip_proximity < -0.5 ? "Short gamma zone" :
"Near flip point"}
</div>
</div>
)}
{row.gamma_exposure !== null && row.gamma_exposure !== undefined && (
<div>
<span className="text-slate-500">Gamma Exposure:</span>
<span className="ml-2 text-slate-300 font-semibold">
{Math.abs(row.gamma_exposure) >= 1e9
? `${(row.gamma_exposure / 1e9).toFixed(2)}B`
: Math.abs(row.gamma_exposure) >= 1e6
? `${(row.gamma_exposure / 1e6).toFixed(1)}M`
: `${(row.gamma_exposure / 1e3).toFixed(0)}K`}
</span>
</div>
)}
{row.dealer_pain_level !== null && row.dealer_pain_level !== undefined && (
<div>
<span className="text-slate-500">Dealer Pain Level:</span>
<span className={cn(
"ml-2 font-semibold",
row.dealer_pain_level >= 70 ? "text-red-400" :
row.dealer_pain_level >= 50 ? "text-orange-400" :
"text-yellow-400"
)}>
{Math.round(row.dealer_pain_level)}
</span>
</div>
)}
{row.flow_state && (
<div>
<span className="text-slate-500">Flow State:</span>
<span className={cn(
"ml-2 font-semibold",
row.flow_state === 'ACTIONABLE' ? "text-green-400" : "text-slate-400"
)}>
{row.flow_state === 'ACTIONABLE' ? '✅ ACTIONABLE' : '📋 INFORMATIONAL'}
</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,110 @@
import { cn } from '@/utils/cn';
export function FlowDetailsSection({
row,
flowTrend,
lastFlowMinutesAgo,
hoursAgo,
netPremium,
bullTotal,
bearTotal,
volume,
oi
}) {
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">🔹 FLOW DETAILS</h4>
{/* Flow Trend Visual */}
{flowTrend && (
<div className={cn(
"mb-4 p-4 border-2 rounded-lg",
flowTrend.color === 'red' && flowTrend.label === 'DEAD'
? "bg-red-900/30 border-red-500"
: flowTrend.color === 'red' && flowTrend.label === 'SURGING'
? "bg-red-900/30 border-red-500"
: flowTrend.color === 'yellow'
? "bg-yellow-900/30 border-yellow-500"
: "bg-orange-900/30 border-orange-500"
)}>
<div className="flex items-center gap-3">
<span className="text-5xl">{flowTrend.icon}</span>
<div>
<div className={cn(
"text-2xl font-bold",
flowTrend.color === 'red' ? "text-red-400" :
flowTrend.color === 'yellow' ? "text-yellow-400" :
"text-orange-400"
)}>
{flowTrend.label} FLOW
</div>
<div className="text-sm text-gray-300">
Last activity: <span className={cn(
"font-bold",
flowTrend.color === 'red' ? "text-red-300" :
flowTrend.color === 'yellow' ? "text-yellow-300" :
"text-orange-300"
)}>
{lastFlowMinutesAgo} minutes ago {hoursAgo > 0 && `(${hoursAgo} ${hoursAgo === 1 ? 'hour' : 'hours'})`}
</span>
</div>
<div className={cn(
"text-sm mt-1",
flowTrend.color === 'red' ? "text-yellow-300" :
flowTrend.color === 'yellow' ? "text-yellow-200" :
"text-orange-200"
)}>
{flowTrend.message}
</div>
</div>
</div>
</div>
)}
<div className="space-y-2 text-xs">
<div>
<span className="text-slate-500">Net Premium:</span>
<span className={cn(
"ml-2 font-semibold",
netPremium > 0 ? "text-green-400" : "text-red-400"
)}>
${(Math.abs(netPremium) / 1000000).toFixed(2)}M
{netPremium > 0 ? ` (${((netPremium / (bullTotal + bearTotal)) * 100).toFixed(0)}% Bullish)` : ` (${((Math.abs(netPremium) / (bullTotal + bearTotal)) * 100).toFixed(0)}% Bearish)`}
</span>
</div>
<div>
<span className="text-slate-500">Premium Breakdown:</span>
<div className="ml-2 mt-1">
<div className="text-green-400">${(bullTotal / 1000000).toFixed(2)}M Calls</div>
<div className="text-red-400">${(bearTotal / 1000000).toFixed(2)}M Puts</div>
</div>
</div>
<div>
<span className="text-slate-500">Flow Type:</span>
<span className="ml-2 text-slate-300">
{row.cp_norm === 'CALL' ? 'ITM Calls' : 'ITM Puts'}
{row.badgesRaw?.more?.includes('💎') ? ' (💎 Real Money)' : ' (Speculation)'}
</span>
</div>
<div>
<span className="text-slate-500">Volume:</span>
<span className="ml-2 text-slate-300">{volume.toLocaleString()} contracts</span>
</div>
{oi > 0 ? (
<div>
<span className="text-slate-500">Open Interest:</span>
<span className="ml-2 text-slate-300">
{oi.toLocaleString()} OI {oi >= 12500 ? '(High)' : oi >= 5000 ? '(Medium)' : '(Low)'}
</span>
</div>
) : (
<div>
<span className="text-slate-500">Open Interest:</span>
<span className="ml-2 text-slate-300 text-slate-400 italic">New flow (no OI yet)</span>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
import { cn } from '@/utils/cn';
export function InstitutionalAnalyticsSection({ row }) {
const hasInstitutionalData = (row.confidence_score !== null && row.confidence_score !== undefined) ||
(row.signal_strength !== null && row.signal_strength !== undefined) ||
(row.relative_premium_score !== null && row.relative_premium_score !== undefined);
if (!hasInstitutionalData) return null;
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">🏛 INSTITUTIONAL ANALYTICS</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{/* Confidence Score */}
{(row.confidence_score !== null && row.confidence_score !== undefined) && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-xs text-slate-500 mb-2">Confidence Score</div>
<div className="flex items-center gap-3">
<div className="text-3xl font-bold text-blue-400">
{Math.round(row.confidence_score)}%
</div>
{row.confidence_score !== null && row.confidence_score !== undefined && (
<div className="flex-1">
<div className="w-full bg-slate-700 rounded-full h-2 mb-1">
<div
className={cn(
"h-2 rounded-full transition-all",
row.confidence_score >= 70 ? "bg-green-500" :
row.confidence_score >= 50 ? "bg-yellow-500" :
"bg-orange-500"
)}
style={{ width: `${Math.min(100, row.confidence_score)}%` }}
/>
</div>
<div className="text-xs text-slate-400">
{row.confidence_score >= 70 ? "High Confidence" :
row.confidence_score >= 50 ? "Moderate" : "Low"}
</div>
</div>
)}
</div>
</div>
)}
{/* Signal Strength */}
{(row.signal_strength !== null && row.signal_strength !== undefined) && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-xs text-slate-500 mb-2">Signal Strength</div>
<div className="flex items-center gap-3">
<div className="text-3xl font-bold text-purple-400">
{Math.round(row.signal_strength)}
</div>
{row.signal_strength !== null && row.signal_strength !== undefined && (
<div className="flex-1">
<div className="w-full bg-slate-700 rounded-full h-2 mb-1">
<div
className={cn(
"h-2 rounded-full transition-all",
row.signal_strength >= 70 ? "bg-purple-500" :
row.signal_strength >= 50 ? "bg-blue-500" :
"bg-slate-500"
)}
style={{ width: `${Math.min(100, row.signal_strength)}%` }}
/>
</div>
<div className="text-xs text-slate-400">
{row.signal_strength >= 70 ? "Strong" :
row.signal_strength >= 50 ? "Moderate" : "Weak"}
</div>
</div>
)}
</div>
</div>
)}
{/* Relative Premium Score */}
{(row.relative_premium_score !== null && row.relative_premium_score !== undefined) && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-xs text-slate-500 mb-2">Relative Premium</div>
<div className="flex items-center gap-3">
<div className="text-3xl font-bold text-green-400">
{Math.round(row.relative_premium_score)}
</div>
{row.relative_premium_score !== null && row.relative_premium_score !== undefined && (
<div className="flex-1">
<div className="w-full bg-slate-700 rounded-full h-2 mb-1">
<div
className={cn(
"h-2 rounded-full transition-all",
row.relative_premium_score >= 70 ? "bg-green-500" :
row.relative_premium_score >= 50 ? "bg-yellow-500" :
"bg-orange-500"
)}
style={{ width: `${Math.min(100, row.relative_premium_score)}%` }}
/>
</div>
<div className="text-xs text-slate-400">
{row.relative_premium_score >= 70 ? "Significant" :
row.relative_premium_score >= 50 ? "Moderate" : "Low"}
</div>
</div>
)}
</div>
{row.premium_zscore !== null && row.premium_zscore !== undefined && (
<div className="mt-2 text-xs text-slate-500">
Z-Score: {row.premium_zscore.toFixed(2)}
</div>
)}
</div>
)}
</div>
{/* Additional context metrics */}
{(row.premium_percentile_intraday !== null || row.aggression_score !== null ||
row.size_concentration_score !== null || row.institutional_likelihood !== null) && (
<div className="mt-4 pt-3 border-t border-slate-700 grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
{row.premium_percentile_intraday !== null && row.premium_percentile_intraday !== undefined && (
<div>
<span className="text-slate-500">Intraday Percentile:</span>
<span className="ml-2 text-slate-300 font-semibold">
{row.premium_percentile_intraday.toFixed(1)}%
</span>
</div>
)}
{row.aggression_score !== null && row.aggression_score !== undefined && (
<div>
<span className="text-slate-500">Aggression:</span>
<span className="ml-2 text-slate-300 font-semibold">
{row.aggression_score.toFixed(0)}
</span>
</div>
)}
{row.size_concentration_score !== null && row.size_concentration_score !== undefined && (
<div>
<span className="text-slate-500">Size Concentration:</span>
<span className="ml-2 text-slate-300 font-semibold">
{row.size_concentration_score.toFixed(0)}
</span>
</div>
)}
{row.institutional_likelihood !== null && row.institutional_likelihood !== undefined && (
<div>
<span className="text-slate-500">Institutional Likelihood:</span>
<span className="ml-2 text-slate-300 font-semibold text-blue-400">
{(row.institutional_likelihood * 100).toFixed(0)}%
</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,123 @@
import { cn } from '@/utils/cn';
import { Badge } from '../../ui/Badge';
export function PriceContextSection({
row,
currentPrice,
vwap,
rthOpen,
pctVsRthOpen,
pctVsPriorClose,
spot,
moneynessPct,
session,
tapeAlign
}) {
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">📊 PRICE CONTEXT</h4>
<div className="space-y-2 text-xs">
<div>
<span className="text-slate-500">Current:</span>
<span className="ml-2 text-slate-200 font-semibold">
${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}
</span>
</div>
<div>
<span className="text-slate-500">VWAP:</span>
<span className="ml-2 text-slate-200">
${typeof vwap === 'number' ? vwap.toFixed(2) : 'N/A'}
</span>
</div>
<div>
<span className="text-slate-500">RTH Open:</span>
<span className="ml-2 text-slate-200">
${typeof rthOpen === 'number' ? rthOpen.toFixed(2) : 'N/A'}
</span>
</div>
{Math.abs(pctVsRthOpen) < 0.01 && Math.abs(pctVsPriorClose) < 0.01 ? (
<div>
<span className="text-slate-500">Price Change:</span>
<span className="ml-2 text-slate-300 font-semibold">0.00% (flat since open)</span>
</div>
) : (
<>
<div>
<span className="text-slate-500">vs RTH Open:</span>
<span className={cn(
"ml-2 font-semibold",
pctVsRthOpen > 0 ? "text-green-400" : "text-red-400"
)}>
{pctVsRthOpen > 0 ? '+' : ''}{typeof pctVsRthOpen === 'number' ? pctVsRthOpen.toFixed(2) : '0.00'}%
</span>
</div>
<div>
<span className="text-slate-500">vs Prior Close:</span>
<span className={cn(
"ml-2 font-semibold",
pctVsPriorClose > 0 ? "text-green-400" : "text-red-400"
)}>
{pctVsPriorClose > 0 ? '+' : ''}{typeof pctVsPriorClose === 'number' ? pctVsPriorClose.toFixed(2) : '0.00'}%
</span>
</div>
</>
)}
<div>
<span className="text-slate-500">Spot Price:</span>
<span className="ml-2 text-slate-200 font-semibold">
${typeof spot === 'number' ? spot.toFixed(2) : spot || 'N/A'}
</span>
</div>
<div>
<span className="text-slate-500">Moneyness:</span>
<span className={cn(
"ml-2 font-semibold",
moneynessPct > 0 ? "text-green-400" : moneynessPct < 0 ? "text-red-400" : "text-slate-300"
)}>
{typeof moneynessPct === 'number' ? moneynessPct.toFixed(2) : moneynessPct || '0.00'}%
</span>
</div>
<div>
<span className="text-slate-500">Session:</span>
<span className="ml-2 text-slate-300">
<Badge variant="outline" className="text-xs">
{session}
</Badge>
</span>
</div>
</div>
{/* Tape Alignment Visual */}
{tapeAlign !== '—' ? (
<div className="mt-4 p-4 bg-green-900/30 border-2 border-green-500 rounded-lg">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<div>
<div className="text-xl font-bold text-green-400">TAPE ALIGNED</div>
<div className="text-sm text-gray-300">Price confirming flow direction </div>
</div>
</div>
</div>
) : (
<div className="mt-4 p-4 bg-orange-900/30 border-2 border-orange-500 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<div>
<div className="text-xl font-bold text-orange-400">NO TAPE ALIGNMENT</div>
<div className="text-sm text-gray-300">Price not confirming flow direction</div>
</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-400">Current Price</div>
<div className="text-2xl font-bold text-orange-300">${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}</div>
<div className="text-xs text-gray-400">Expected: Upward momentum</div>
<div className="text-xs text-red-300">Reality: Flat/down</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
import { cn } from '@/utils/cn';
export function TimeSequencedSection({ row }) {
const hasTimeSequencedData = (row.flow_acceleration !== null && row.flow_acceleration !== undefined) ||
(row.time_between_hits !== null && row.time_between_hits !== undefined) ||
(row.follow_on_ratio !== null && row.follow_on_ratio !== undefined) ||
row.strike_laddering_detected;
if (!hasTimeSequencedData) return null;
return (
<div className="space-y-3 lg:col-span-2">
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2"> TIME-SEQUENCED METRICS</h4>
<div className="bg-slate-800/30 rounded-lg p-4 border border-slate-700">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
{row.flow_acceleration !== null && row.flow_acceleration !== undefined && (
<div>
<span className="text-slate-500">Flow Acceleration:</span>
<span className={cn(
"ml-2 font-semibold",
row.flow_acceleration > 0 ? "text-green-400" : "text-red-400"
)}>
{row.flow_acceleration >= 1e6
? `${(row.flow_acceleration / 1e6).toFixed(2)}M/min`
: `${(row.flow_acceleration / 1e3).toFixed(0)}K/min`}
</span>
<div className="text-xs text-slate-500 mt-0.5">
{row.flow_acceleration > 0 ? "Escalating" : "Decelerating"}
</div>
</div>
)}
{row.time_between_hits !== null && row.time_between_hits !== undefined && (
<div>
<span className="text-slate-500">Time Between Hits:</span>
<span className="ml-2 text-slate-300 font-semibold">
{row.time_between_hits.toFixed(1)} min
</span>
</div>
)}
{row.follow_on_ratio !== null && row.follow_on_ratio !== undefined && (
<div>
<span className="text-slate-500">Follow-On Ratio:</span>
<span className={cn(
"ml-2 font-semibold",
row.follow_on_ratio >= 0.7 ? "text-green-400" :
row.follow_on_ratio >= 0.4 ? "text-yellow-400" :
"text-red-400"
)}>
{(row.follow_on_ratio * 100).toFixed(0)}%
</span>
<div className="text-xs text-slate-500 mt-0.5">
{row.follow_on_ratio >= 0.7 ? "Continuation" :
row.follow_on_ratio >= 0.4 ? "Mixed" : "Reversal"}
</div>
</div>
)}
{row.strike_laddering_detected !== null && row.strike_laddering_detected !== undefined && (
<div>
<span className="text-slate-500">Strike Laddering:</span>
<span className={cn(
"ml-2 font-semibold",
row.strike_laddering_detected ? "text-green-400" : "text-slate-500"
)}>
{row.strike_laddering_detected ? '✅ Detected' : '❌ None'}
</span>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
export function VolumeHistorySection({ row }) {
if (!row.volumeHistory || row.volumeHistory.length === 0) return null;
return (
<div className="space-y-3 md:col-span-2 lg:col-span-3 mt-6 pt-6 border-t border-slate-700/50">
<h4 className="text-sm font-semibold text-slate-300 mb-3">📊 LAST 5 DAYS VOLUME</h4>
<div className="bg-slate-800/50 rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-700/50 bg-slate-800/70">
<th className="text-left py-3 px-4 text-slate-400 font-semibold">Date</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Volume</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Open</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">High</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Low</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Close</th>
<th className="text-right py-3 px-4 text-slate-400 font-semibold">VWAP</th>
</tr>
</thead>
<tbody>
{row.volumeHistory.map((day, idx) => {
const date = new Date(day.date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dayDate = new Date(date);
dayDate.setHours(0, 0, 0, 0);
let dayLabel;
if (dayDate.getTime() === today.getTime()) {
dayLabel = 'Today';
} else if (dayDate.getTime() === yesterday.getTime()) {
dayLabel = 'Yesterday';
} else {
dayLabel = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// Format volume with K/M/B
const formatVolume = (vol) => {
if (!vol || vol === 0) return '—';
const absVal = Math.abs(vol);
if (absVal >= 1e9) return `${(absVal / 1e9).toFixed(2)}B`;
if (absVal >= 1e6) return `${(absVal / 1e6).toFixed(2)}M`;
if (absVal >= 1e3) return `${(absVal / 1e3).toFixed(2)}K`;
return absVal.toLocaleString();
};
// Format price
const formatPrice = (price) => {
if (!price || price === 0) return '—';
return `$${parseFloat(price).toFixed(2)}`;
};
return (
<tr key={idx} className="border-b border-slate-700/30 hover:bg-slate-800/70 transition-colors">
<td className="py-3 px-4 text-slate-300 font-medium">{dayLabel}</td>
<td className="py-3 px-4 text-right text-slate-200 font-mono">{formatVolume(day.volume)}</td>
<td className="py-3 px-4 text-right text-slate-300 font-mono">{formatPrice(day.open)}</td>
<td className="py-3 px-4 text-right text-green-400 font-mono">{formatPrice(day.high)}</td>
<td className="py-3 px-4 text-right text-red-400 font-mono">{formatPrice(day.low)}</td>
<td className="py-3 px-4 text-right text-slate-200 font-mono font-semibold">{formatPrice(day.close)}</td>
<td className="py-3 px-4 text-right text-blue-400 font-mono font-semibold">{formatPrice(day.vwap)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

48
k8s/app.yaml Normal file
View File

@ -0,0 +1,48 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: market-app
namespace: ai-agents
spec:
replicas: 1
selector:
matchLabels:
app: market-app
template:
metadata:
labels:
app: market-app
spec:
containers:
- name: market
image: 192.168.8.250:5000/market:latest
imagePullPolicy: Always
ports:
- containerPort: 3010
env:
- name: NODE_ENV
value: "production"
livenessProbe:
httpGet:
path: /health
port: 3010
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: 3010
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: market-app
namespace: ai-agents
spec:
selector:
app: market-app
ports:
- port: 80
targetPort: 3010

20
k8s/ingress.yaml Normal file
View File

@ -0,0 +1,20 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: market-ingress
namespace: ai-agents
annotations:
k8s.apisix.apache.org/enable-websocket: "true"
spec:
ingressClassName: apisix
rules:
- host: market.applaude.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: market-app
port:
number: 80