Deploy to Talos K8s: add Market Analysis and Docker/K8s manifests
This commit is contained in:
parent
7ec16dc655
commit
4774890415
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -58,7 +58,14 @@ USE_PYTHON_SERVICE=true
|
|||
### Terminal 1: Python Service
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@
|
|||
"node-cache": "^5.1.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.11.3",
|
||||
"ws": "^8.14.2"
|
||||
"ws": "^8.14.2",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
|
@ -1499,6 +1500,15 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"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": {
|
||||
"version": "7.7.3",
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"node-cache": "^5.1.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.11.3",
|
||||
"ws": "^8.14.2"
|
||||
"ws": "^8.14.2",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
Binary file not shown.
|
|
@ -192,6 +192,63 @@ async def get_options_flow(
|
|||
# Recalculate rocket score with price context and alerts
|
||||
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):
|
||||
# Initialize Phase 1 columns with None/empty values first
|
||||
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)
|
||||
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
|
||||
if 'premium_num' in df_final.columns:
|
||||
before_premium = len(df_final)
|
||||
|
|
@ -268,6 +332,14 @@ async def get_options_flow(
|
|||
else:
|
||||
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:
|
||||
logger.warning("⚠️ No data after premium filter")
|
||||
return OptionsFlowResponse(
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -146,5 +146,14 @@ class OutputFormatter:
|
|||
# Ensure Phase 1 columns are preserved (don't drop them)
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -3,7 +3,13 @@
|
|||
|
||||
# Activate virtual environment if it exists
|
||||
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
|
||||
|
||||
# Start the service
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import express from 'express';
|
||||
import { backtestSignal } from '../services/backtester.js';
|
||||
import { rawQuery } from '../db.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -27,9 +28,6 @@ router.post('/run', async (req, res) => {
|
|||
|
||||
const {
|
||||
lookbackDays = 30,
|
||||
exitStrategy = 'target_stop',
|
||||
targetPct = 1.5,
|
||||
stopPct = 1.5,
|
||||
minOccurrences = 10
|
||||
} = 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import optionsFlowRouter from './routes/optionsFlow.js';
|
||||
import dailyAnalysisRouter from './routes/dailyAnalysis.js';
|
||||
import pricesRouter from './routes/prices.js';
|
||||
|
|
@ -11,6 +13,7 @@ import tradePlansRouter from './routes/tradePlans.js';
|
|||
import performanceRouter from './routes/performance.js';
|
||||
import reversalsRouter from './routes/reversals.js';
|
||||
import convergenceRouter from './routes/convergence.js';
|
||||
import marketAnalysisRouter from './routes/marketAnalysis.js';
|
||||
import backtestRouter from './routes/backtest.js';
|
||||
import healthRouter from './routes/health.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/reversals', reversalsRouter);
|
||||
app.use('/api/convergence', convergenceRouter);
|
||||
app.use('/api/market', marketAnalysisRouter);
|
||||
app.use('/api/backtest', backtestRouter);
|
||||
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)
|
||||
app.use(errorHandler);
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ async function getFetch() {
|
|||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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®ion=US&lang=en-US'),
|
||||
]);
|
||||
|
||||
const items = [
|
||||
...(spyNews.status === 'fulfilled' ? spyNews.value : []),
|
||||
].slice(0, 20);
|
||||
|
||||
newsCache.set(cacheKey, items);
|
||||
return items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -12,11 +12,9 @@
|
|||
"react-dom": "^18.2.0",
|
||||
"zustand": "^4.4.7",
|
||||
"@tanstack/react-query": "^5.12.2",
|
||||
"lightweight-charts": "^4.1.3",
|
||||
"lucide-react": "^0.294.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"d3": "^7.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { useState } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import OptionsFlowPanel from '@/components/dashboard/OptionsFlowPanel';
|
||||
import { DailyAnalysisPanel } from '@/components/dashboard/DailyAnalysisPanel';
|
||||
import ScannerPanel from '@/components/dashboard/ScannerPanel';
|
||||
import PhaseClassifierPanel from '@/components/dashboard/PhaseClassifierPanel';
|
||||
import BacktestPanel from '@/components/dashboard/BacktestPanel';
|
||||
import AlertsFeed from '@/components/dashboard/AlertsFeed';
|
||||
import Watchlist from '@/components/dashboard/Watchlist';
|
||||
import PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel';
|
||||
import FlowInfoPanel from '@/components/dashboard/FlowInfoPanel';
|
||||
import ReversalAlerts from '@/components/alerts/ReversalAlerts';
|
||||
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() {
|
||||
const [selectedSymbol, setSelectedSymbol] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
<ConvergenceAlerts />
|
||||
|
|
@ -46,17 +48,14 @@ export default function App() {
|
|||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Left: Main Panels */}
|
||||
<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">
|
||||
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
|
||||
📊 Market Analysis
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
|
||||
🎯 Options Flow
|
||||
</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">
|
||||
🔍 Multi-Signal Scanner
|
||||
</TabsTrigger>
|
||||
|
|
@ -65,18 +64,20 @@ export default function App() {
|
|||
</TabsTrigger>
|
||||
</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">
|
||||
<OptionsFlowPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="phase" className="mt-6">
|
||||
<PhaseClassifierPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="daily" className="mt-6">
|
||||
<DailyAnalysisPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scanner" className="mt-6">
|
||||
<ScannerPanel />
|
||||
</TabsContent>
|
||||
|
|
@ -87,15 +88,15 @@ export default function App() {
|
|||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Right: Flow Info, Watchlist & Alerts Feed */}
|
||||
{/* Right: News, Watchlist & Alerts Feed */}
|
||||
<div className="col-span-3 space-y-6">
|
||||
<FlowInfoPanel />
|
||||
<NewsFeedPanel />
|
||||
<Watchlist />
|
||||
<AlertsFeed />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Today's Signals */}
|
||||
{/* Bottom: Performance Tracking */}
|
||||
<div className="mt-6">
|
||||
<PerformanceTrackingPanel />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,128 +1 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function() { return null; }
|
||||
|
|
|
|||
|
|
@ -1,191 +1 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export default function() { return null; }
|
||||
|
|
|
|||
|
|
@ -1,154 +1,611 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
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';
|
||||
|
||||
export default function BacktestPanel() {
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
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(() => {
|
||||
fetchBacktests();
|
||||
}, []);
|
||||
|
||||
const fetchBacktests = async () => {
|
||||
// Fetch symbols for selected date
|
||||
const fetchSymbolsForDate = async (date, premium = minPremium) => {
|
||||
setLoading(true);
|
||||
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();
|
||||
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) {
|
||||
console.error('Backtest fetch error:', error);
|
||||
console.error('Error fetching symbols:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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">Running backtests...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Fetch performance for selected symbol
|
||||
const fetchSymbolPerformance = async (symbol, date) => {
|
||||
setLoadingPerformance(true);
|
||||
setSelectedSymbol(symbol);
|
||||
try {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Strategy Backtests (90 Days)</h2>
|
||||
<button
|
||||
onClick={fetchBacktests}
|
||||
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>
|
||||
<h2 className="text-2xl font-bold">Historical Signal Backtest</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
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>
|
||||
|
||||
{results.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
No backtest results available. Try refreshing or check the backend logs.
|
||||
{/* Date Selection Info */}
|
||||
<div className="p-4 bg-slate-900 border border-slate-800 rounded-lg">
|
||||
<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 > ${(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 className="grid gap-4">
|
||||
{results.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-6 bg-slate-900 border border-slate-800 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">{result.name}</h3>
|
||||
<Badge
|
||||
variant={parseFloat(result.winRate) >= 60 ? 'success' : parseFloat(result.winRate) >= 50 ? 'warning' : 'destructive'}
|
||||
className="text-lg px-3 py-1"
|
||||
>
|
||||
{result.winRate}% Win Rate
|
||||
</Badge>
|
||||
{/* Right: Performance Analysis */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5" />
|
||||
Performance Analysis
|
||||
</h3>
|
||||
|
||||
{loadingPerformance ? (
|
||||
<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">Analyzing performance...</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-slate-800/50 rounded">
|
||||
<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>
|
||||
) : !performance ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
Select a symbol to view performance analysis
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-green-400 mb-2 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Best Conditions
|
||||
</div>
|
||||
<ul className="space-y-1 text-xs text-slate-300">
|
||||
{result.bestConditions && result.bestConditions.length > 0 ? (
|
||||
result.bestConditions.map((cond, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<span className="text-green-400">•</span>
|
||||
<span>{cond.condition}:</span>
|
||||
<span className="font-semibold text-green-400">{cond.winRate}</span>
|
||||
<span className="text-slate-500">({cond.occurrences} trades)</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Card */}
|
||||
<div className={`p-6 rounded-lg border-2 ${
|
||||
performance.performance.wouldBeProfitable
|
||||
? 'bg-green-900/20 border-green-600/50'
|
||||
: 'bg-red-900/20 border-red-600/50'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-xl font-bold">{performance.symbol}</h4>
|
||||
{performance.performance.wouldBeProfitable ? (
|
||||
<TrendingUp className="w-8 h-8 text-green-400" />
|
||||
) : (
|
||||
<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>
|
||||
<ul className="space-y-1 text-xs text-slate-300">
|
||||
{result.worstConditions && result.worstConditions.length > 0 ? (
|
||||
result.worstConditions.map((cond, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<span className="text-red-400">•</span>
|
||||
<span>{cond.condition}:</span>
|
||||
<span className="font-semibold text-red-400">{cond.winRate}</span>
|
||||
<span className="text-slate-500">({cond.occurrences} trades)</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-slate-500">No data available</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Entry Price:</span>
|
||||
<span className="font-semibold">${Number(performance.entryPrice || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Final Change:</span>
|
||||
<span className={`font-semibold ${
|
||||
Number(performance.performance?.finalGain || 0) >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{Number(performance.performance?.finalGain || 0) >= 0 ? '+' : ''}
|
||||
{Number(performance.performance?.finalGain || 0).toFixed(2)}%
|
||||
</span>
|
||||
</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-red-900/20 border border-red-600/50 rounded text-sm text-red-400">
|
||||
⚠️ Error: {result.error}
|
||||
<div className="mt-4 p-3 bg-slate-800/50 rounded">
|
||||
<p className="text-sm text-slate-300">
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -291,28 +291,28 @@ export function OptionsFlowCardList({ data, onCardClick, selectedRow, reversalsB
|
|||
{row.stockPrice && row.stockPrice.currentPrice && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<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>
|
||||
{row.stockPrice.changePercent !== undefined && (
|
||||
{row.stockPrice.changePercent !== undefined && row.stockPrice.changePercent !== null && (
|
||||
<span className={cn(
|
||||
"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-red-400"
|
||||
)}>
|
||||
{row.stockPrice.changePercent >= 0 ? '+' : ''}
|
||||
{row.stockPrice.changePercent.toFixed(2)}%
|
||||
{(typeof row.stockPrice.changePercent === 'number' ? row.stockPrice.changePercent : parseFloat(row.stockPrice.changePercent) || 0) >= 0 ? '+' : ''}
|
||||
{(typeof row.stockPrice.changePercent === 'number' ? row.stockPrice.changePercent : parseFloat(row.stockPrice.changePercent) || 0).toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
{row.stockPrice.change !== undefined && (
|
||||
{row.stockPrice.change !== undefined && row.stockPrice.change !== null && (
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
row.stockPrice.change >= 0
|
||||
(typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0) >= 0
|
||||
? "text-green-400"
|
||||
: "text-red-400"
|
||||
)}>
|
||||
({row.stockPrice.change >= 0 ? '+' : ''}
|
||||
{row.stockPrice.change.toFixed(2)})
|
||||
({(typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0) >= 0 ? '+' : ''}
|
||||
{(typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0).toFixed(2)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -446,6 +446,197 @@ export function OptionsFlowCardList({ data, onCardClick, selectedRow, reversalsB
|
|||
</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 !== 'NEUTRAL' && signal.signal !== 'WAIT' && (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { getApiUrl } from '@/config/api';
|
|||
// Column visibility configuration
|
||||
const COLUMN_GROUPS = {
|
||||
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'],
|
||||
volume: ['Volume', 'OI', 'Price', 'Side'],
|
||||
price: ['PctVsPriorClose', 'PctVsRthOpen', 'Pct5m', 'Pct15m', 'PriceReaction5m', 'VWAPDistance'],
|
||||
|
|
@ -54,6 +55,7 @@ export default function OptionsFlowPanel() {
|
|||
const [reversals, setReversals] = useState([]);
|
||||
const [convergences, setConvergences] = useState([]);
|
||||
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 { data, loading, error, filteredCount, refetch } = useOptionsFlow({
|
||||
|
|
@ -78,6 +80,11 @@ export default function OptionsFlowPanel() {
|
|||
const flowLedToMove = row.flow_led_to_move === true;
|
||||
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;
|
||||
score += rocketScore * 10; // 0-100 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
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -219,6 +231,24 @@ export default function OptionsFlowPanel() {
|
|||
return filtered;
|
||||
}, [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
|
||||
// Filter: items repeated more than once, more than 2 rockets, highest premium
|
||||
const topTrades = useMemo(() => {
|
||||
|
|
@ -388,17 +418,17 @@ export default function OptionsFlowPanel() {
|
|||
{stockPrice && stockPrice.currentPrice && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{stockPrice.changePercent !== undefined && (
|
||||
{stockPrice.changePercent !== undefined && stockPrice.changePercent !== null && (
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
stockPrice.changePercent >= 0
|
||||
(typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0) >= 0
|
||||
? "text-green-400"
|
||||
: "text-red-400"
|
||||
)}>
|
||||
{stockPrice.changePercent >= 0 ? '+' : ''}
|
||||
{stockPrice.changePercent.toFixed(2)}%
|
||||
{(typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0) >= 0 ? '+' : ''}
|
||||
{(typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0).toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -513,7 +543,8 @@ export default function OptionsFlowPanel() {
|
|||
},
|
||||
cell: ({ row }) => {
|
||||
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 percentage = Math.min((score / maxScore) * 100, 100);
|
||||
|
||||
|
|
@ -788,9 +819,10 @@ export default function OptionsFlowPanel() {
|
|||
// Add temporal explanation
|
||||
if (trendValue === 'DEAD') {
|
||||
if (minutesAgo !== null && minutesAgo >= 60) {
|
||||
const premiumText = premium >= 1000000
|
||||
? `$${(premium / 1000000).toFixed(1)}M premium`
|
||||
: `$${(premium / 1000).toFixed(0)}K premium`;
|
||||
const numPremium = typeof premium === 'number' ? premium : parseFloat(premium) || 0;
|
||||
const premiumText = numPremium >= 1000000
|
||||
? `$${(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.temporalExplanation = `Flow occurred earlier; no continuation in last ${minutesAgo}min`;
|
||||
} else {
|
||||
|
|
@ -1006,9 +1038,11 @@ export default function OptionsFlowPanel() {
|
|||
cell: ({ row }) => {
|
||||
const val = row.original.PctVsPriorClose;
|
||||
if (val === null || val === undefined) return '—';
|
||||
const numVal = typeof val === 'number' ? val : parseFloat(val);
|
||||
if (isNaN(numVal)) return '—';
|
||||
return (
|
||||
<span className={`font-medium ${val > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{val > 0 ? '+' : ''}{val.toFixed(2)}%
|
||||
<span className={`font-medium ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1020,9 +1054,11 @@ export default function OptionsFlowPanel() {
|
|||
cell: ({ row }) => {
|
||||
const val = row.original.PctVsRthOpen || row.original.pct_vs_rth_open;
|
||||
if (val === null || val === undefined) return '—';
|
||||
const numVal = typeof val === 'number' ? val : parseFloat(val);
|
||||
if (isNaN(numVal)) return '—';
|
||||
return (
|
||||
<span className={`font-medium ${val > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{val > 0 ? '+' : ''}{val.toFixed(2)}%
|
||||
<span className={`font-medium ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1034,9 +1070,11 @@ export default function OptionsFlowPanel() {
|
|||
cell: ({ row }) => {
|
||||
const val = row.original.Pct5m || row.original.pct_5m_momo;
|
||||
if (val === null || val === undefined) return '—';
|
||||
const numVal = typeof val === 'number' ? val : parseFloat(val);
|
||||
if (isNaN(numVal)) return '—';
|
||||
return (
|
||||
<span className={`text-xs ${val > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{val > 0 ? '+' : ''}{val.toFixed(2)}%
|
||||
<span className={`text-xs ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1048,9 +1086,11 @@ export default function OptionsFlowPanel() {
|
|||
cell: ({ row }) => {
|
||||
const val = row.original.Pct15m || row.original.pct_15m_momo;
|
||||
if (val === null || val === undefined) return '—';
|
||||
const numVal = typeof val === 'number' ? val : parseFloat(val);
|
||||
if (isNaN(numVal)) return '—';
|
||||
return (
|
||||
<span className={`text-xs ${val > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{val > 0 ? '+' : ''}{val.toFixed(2)}%
|
||||
<span className={`text-xs ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
|
||||
</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
|
||||
{
|
||||
accessorKey: 'PriceReaction5m',
|
||||
|
|
@ -1208,11 +1360,13 @@ export default function OptionsFlowPanel() {
|
|||
const ledToMove = row.original.flow_led_to_move;
|
||||
|
||||
if (reaction === null || reaction === undefined) return '—';
|
||||
const numReaction = typeof reaction === 'number' ? reaction : parseFloat(reaction);
|
||||
if (isNaN(numReaction)) return '—';
|
||||
|
||||
return (
|
||||
<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'}`}>
|
||||
{reaction > 0 ? '+' : ''}{reaction.toFixed(2)}%
|
||||
<span className={`text-xs font-medium ${numReaction > 0 ? 'text-green-400' : numReaction < 0 ? 'text-red-400' : 'text-slate-400'}`}>
|
||||
{numReaction > 0 ? '+' : ''}{numReaction.toFixed(2)}%
|
||||
</span>
|
||||
{ledToMove && (
|
||||
<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>;
|
||||
return '—';
|
||||
}
|
||||
const numVwapDist = typeof vwapDist === 'number' ? vwapDist : parseFloat(vwapDist);
|
||||
if (isNaN(numVwapDist)) return '—';
|
||||
|
||||
// Color code: near VWAP = good, far from VWAP = caution
|
||||
const absDist = Math.abs(vwapDist);
|
||||
const absDist = Math.abs(numVwapDist);
|
||||
let colorClass = 'text-slate-400';
|
||||
if (absDist <= 1) colorClass = 'text-green-400';
|
||||
else if (absDist <= 2) colorClass = 'text-yellow-400';
|
||||
else colorClass = 'text-red-400';
|
||||
|
||||
const numVwap = typeof vwap === 'number' ? vwap : parseFloat(vwap);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className={`text-xs font-medium ${colorClass}`}>
|
||||
{vwapDist > 0 ? '+' : ''}{vwapDist.toFixed(2)}%
|
||||
{numVwapDist > 0 ? '+' : ''}{numVwapDist.toFixed(2)}%
|
||||
</span>
|
||||
{vwap && (
|
||||
<span className="text-xs text-slate-500" title={`VWAP: $${vwap.toFixed(2)}`}>
|
||||
V: ${vwap.toFixed(2)}
|
||||
{vwap && !isNaN(numVwap) && (
|
||||
<span className="text-xs text-slate-500" title={`VWAP: $${numVwap.toFixed(2)}`}>
|
||||
V: ${numVwap.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1541,6 +1699,29 @@ export default function OptionsFlowPanel() {
|
|||
|
||||
{/* View Mode Toggle & Column Selector */}
|
||||
<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">
|
||||
<button
|
||||
onClick={() => setViewMode('cards')}
|
||||
|
|
@ -1671,7 +1852,7 @@ export default function OptionsFlowPanel() {
|
|||
{viewMode === 'cards' ? (
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/50 rounded-lg shadow-lg p-4">
|
||||
<OptionsFlowCardList
|
||||
data={filteredData}
|
||||
data={cardViewFilteredData}
|
||||
onCardClick={handleRowClick}
|
||||
selectedRow={selectedRow}
|
||||
reversalsBySymbol={reversalsBySymbol}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +1,25 @@
|
|||
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 { 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 }) {
|
||||
const [showAIAnalysis, setShowAIAnalysis] = useState(false);
|
||||
const [showPhase1Modal, setShowPhase1Modal] = useState(false);
|
||||
const { loading: aiLoading, error: aiError, analysis: aiAnalysis, fetchAnalysis } = useAIAnalysis();
|
||||
|
||||
// Check if Phase 1 data exists
|
||||
const hasPhase1Data = row.signal_tier !== undefined || row.checklist_passed !== undefined || row.flow_led_to_move !== undefined;
|
||||
|
||||
const signal = row.tradeSignal;
|
||||
const score = row.rocketScore || row.rocket_score || 0;
|
||||
const netPremium = (row.bull_total || 0) - (row.bear_total || 0);
|
||||
const bullTotal = row.bull_total || 0;
|
||||
const bearTotal = row.bear_total || 0;
|
||||
const premium = row.premium_num || 0;
|
||||
const volume = row.Volume || row.vol_num || 0;
|
||||
const oi = row.OI || row.oi_num || 0;
|
||||
const currentPriceRaw = row.u_close || row.spot_num || row.Price || 0;
|
||||
|
|
@ -42,8 +42,6 @@ export function ExpandedRowDetails({ row }) {
|
|||
const expirationDate = row.ExpirationDate;
|
||||
|
||||
// Additional important details
|
||||
const side = row.Side || '—';
|
||||
const price = row.Price || 0;
|
||||
const spot = row.Spot || row.u_close || row.spot_num || 0;
|
||||
const moneynessPct = row.moneyness_pct || 0;
|
||||
const lastFlowMinutesAgo = trend?.lastFlowMinutesAgo ?? decay?.lastFlowMinutesAgo;
|
||||
|
|
@ -106,9 +104,8 @@ export function ExpandedRowDetails({ row }) {
|
|||
const formatExpirationDate = (date) => {
|
||||
if (!date) return 'N/A';
|
||||
try {
|
||||
// Handle both ISO format and M/D/YYYY format
|
||||
if (typeof date === 'string' && date.includes('/')) {
|
||||
return date; // Already in M/D/YYYY format
|
||||
return date;
|
||||
}
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
|
|
@ -145,211 +142,46 @@ export function ExpandedRowDetails({ row }) {
|
|||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3">🔹 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>
|
||||
)}
|
||||
<FlowDetailsSection
|
||||
row={row}
|
||||
flowTrend={flowTrend}
|
||||
lastFlowMinutesAgo={lastFlowMinutesAgo}
|
||||
hoursAgo={hoursAgo}
|
||||
netPremium={netPremium}
|
||||
bullTotal={bullTotal}
|
||||
bearTotal={bearTotal}
|
||||
volume={volume}
|
||||
oi={oi}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{/* Institutional Analytics - Phase 1 */}
|
||||
<InstitutionalAnalyticsSection row={row} />
|
||||
|
||||
{/* Phase 3: Advanced Dealer & Regime Analytics */}
|
||||
<DealerRegimeSection row={row} />
|
||||
|
||||
{/* Time-Sequenced Metrics - Separate Section */}
|
||||
<TimeSequencedSection row={row} />
|
||||
|
||||
{/* Price Context */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3">📊 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>
|
||||
<PriceContextSection
|
||||
row={row}
|
||||
currentPrice={currentPrice}
|
||||
vwap={vwap}
|
||||
rthOpen={rthOpen}
|
||||
pctVsRthOpen={pctVsRthOpen}
|
||||
pctVsPriorClose={pctVsPriorClose}
|
||||
spot={spot}
|
||||
moneynessPct={moneynessPct}
|
||||
session={session}
|
||||
tapeAlign={tapeAlign}
|
||||
/>
|
||||
|
||||
{/* Timing & Dates */}
|
||||
<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>
|
||||
<span className="text-slate-500">Created Date:</span>
|
||||
|
|
@ -387,7 +219,7 @@ export function ExpandedRowDetails({ row }) {
|
|||
|
||||
{/* Catalyst */}
|
||||
<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">
|
||||
{catalyst !== 'None' ? (
|
||||
<>
|
||||
|
|
@ -490,183 +322,14 @@ export function ExpandedRowDetails({ row }) {
|
|||
)}
|
||||
|
||||
{/* Phase 1 & AI Analysis */}
|
||||
<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={() => 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>
|
||||
<AnalysisSection
|
||||
row={row}
|
||||
hasPhase1Data={hasPhase1Data}
|
||||
onPhase1Click={() => setShowPhase1Modal(true)}
|
||||
/>
|
||||
|
||||
{/* Last 5 Days Volume History - Table Format */}
|
||||
{row.volumeHistory && row.volumeHistory.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
<VolumeHistorySection row={row} />
|
||||
</div>
|
||||
|
||||
{/* Phase 1 Eligibility Modal */}
|
||||
|
|
@ -678,4 +341,3 @@ export function ExpandedRowDetails({ row }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue