diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..5fff850 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..758064a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README/QUICK_START.md b/README/QUICK_START.md index 5c0c4e2..dfb8944 100644 --- a/README/QUICK_START.md +++ b/README/QUICK_START.md @@ -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 ``` diff --git a/backend/package-lock.json b/backend/package-lock.json index ad29d60..95876f9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index c52df94..d7a74d5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/python_service/EXAMPLE_OUTPUT.json b/backend/python_service/EXAMPLE_OUTPUT.json new file mode 100644 index 0000000..7fd13c2 --- /dev/null +++ b/backend/python_service/EXAMPLE_OUTPUT.json @@ -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" +} + diff --git a/backend/python_service/INSTITUTIONAL_ANALYTICS_README.md b/backend/python_service/INSTITUTIONAL_ANALYTICS_README.md new file mode 100644 index 0000000..b614ddd --- /dev/null +++ b/backend/python_service/INSTITUTIONAL_ANALYTICS_README.md @@ -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) + diff --git a/backend/python_service/__pycache__/main.cpython-314.pyc b/backend/python_service/__pycache__/main.cpython-314.pyc index a4f9b70..76eecbf 100644 Binary files a/backend/python_service/__pycache__/main.cpython-314.pyc and b/backend/python_service/__pycache__/main.cpython-314.pyc differ diff --git a/backend/python_service/main.py b/backend/python_service/main.py index 7225713..b19f84f 100644 --- a/backend/python_service/main.py +++ b/backend/python_service/main.py @@ -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( diff --git a/backend/python_service/services/__pycache__/options_flow_processor.cpython-314.pyc b/backend/python_service/services/__pycache__/options_flow_processor.cpython-314.pyc index 3804dbe..8bf1b61 100644 Binary files a/backend/python_service/services/__pycache__/options_flow_processor.cpython-314.pyc and b/backend/python_service/services/__pycache__/options_flow_processor.cpython-314.pyc differ diff --git a/backend/python_service/services/__pycache__/output_formatter.cpython-314.pyc b/backend/python_service/services/__pycache__/output_formatter.cpython-314.pyc index 85ac8b4..97b353d 100644 Binary files a/backend/python_service/services/__pycache__/output_formatter.cpython-314.pyc and b/backend/python_service/services/__pycache__/output_formatter.cpython-314.pyc differ diff --git a/backend/python_service/services/dealer_flow_context.py b/backend/python_service/services/dealer_flow_context.py new file mode 100644 index 0000000..94c98c2 --- /dev/null +++ b/backend/python_service/services/dealer_flow_context.py @@ -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 + diff --git a/backend/python_service/services/flow_decay_validator.py b/backend/python_service/services/flow_decay_validator.py new file mode 100644 index 0000000..0060a03 --- /dev/null +++ b/backend/python_service/services/flow_decay_validator.py @@ -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 + diff --git a/backend/python_service/services/institutional_confidence.py b/backend/python_service/services/institutional_confidence.py new file mode 100644 index 0000000..50d2f78 --- /dev/null +++ b/backend/python_service/services/institutional_confidence.py @@ -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 + diff --git a/backend/python_service/services/intent_classifier.py b/backend/python_service/services/intent_classifier.py new file mode 100644 index 0000000..66c3ab0 --- /dev/null +++ b/backend/python_service/services/intent_classifier.py @@ -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 + diff --git a/backend/python_service/services/market_regime_detector.py b/backend/python_service/services/market_regime_detector.py new file mode 100644 index 0000000..a8b9aa6 --- /dev/null +++ b/backend/python_service/services/market_regime_detector.py @@ -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 + diff --git a/backend/python_service/services/noise_rejector.py b/backend/python_service/services/noise_rejector.py new file mode 100644 index 0000000..e8a90b1 --- /dev/null +++ b/backend/python_service/services/noise_rejector.py @@ -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 + diff --git a/backend/python_service/services/output_formatter.py b/backend/python_service/services/output_formatter.py index 5ce3755..640092d 100644 --- a/backend/python_service/services/output_formatter.py +++ b/backend/python_service/services/output_formatter.py @@ -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 diff --git a/backend/python_service/services/relative_premium_scorer.py b/backend/python_service/services/relative_premium_scorer.py new file mode 100644 index 0000000..7aa1eeb --- /dev/null +++ b/backend/python_service/services/relative_premium_scorer.py @@ -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 + diff --git a/backend/python_service/services/signal_component_scorer.py b/backend/python_service/services/signal_component_scorer.py new file mode 100644 index 0000000..e31bc68 --- /dev/null +++ b/backend/python_service/services/signal_component_scorer.py @@ -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 + diff --git a/backend/python_service/services/time_sequenced_analyzer.py b/backend/python_service/services/time_sequenced_analyzer.py new file mode 100644 index 0000000..748adfc --- /dev/null +++ b/backend/python_service/services/time_sequenced_analyzer.py @@ -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 + diff --git a/backend/python_service/start.sh b/backend/python_service/start.sh index 4d990dc..f90fa45 100644 --- a/backend/python_service/start.sh +++ b/backend/python_service/start.sh @@ -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 diff --git a/backend/scripts/backtestNFLXPuts.js b/backend/scripts/backtestNFLXPuts.js new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/backtest.js b/backend/src/routes/backtest.js index 38dd08c..eadc50f 100644 --- a/backend/src/routes/backtest.js +++ b/backend/src/routes/backtest.js @@ -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; diff --git a/backend/src/routes/marketAnalysis.js b/backend/src/routes/marketAnalysis.js new file mode 100644 index 0000000..a01f47f --- /dev/null +++ b/backend/src/routes/marketAnalysis.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 97c524c..7333ed6 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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); diff --git a/backend/src/services/blackboxApiService.js b/backend/src/services/blackboxApiService.js index c7b6194..4178057 100644 --- a/backend/src/services/blackboxApiService.js +++ b/backend/src/services/blackboxApiService.js @@ -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 diff --git a/backend/src/services/fundamentalsService.js b/backend/src/services/fundamentalsService.js new file mode 100644 index 0000000..88e0f80 --- /dev/null +++ b/backend/src/services/fundamentalsService.js @@ -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, + }; +} diff --git a/backend/src/services/institutionalHoldersService.js b/backend/src/services/institutionalHoldersService.js new file mode 100644 index 0000000..017e404 --- /dev/null +++ b/backend/src/services/institutionalHoldersService.js @@ -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; + } +} diff --git a/backend/src/services/newsService.js b/backend/src/services/newsService.js new file mode 100644 index 0000000..49191fd --- /dev/null +++ b/backend/src/services/newsService.js @@ -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: TITLE...DATE + const newsRowRegex = /]+class="[^"]*tab-link[^"]*"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi; + const dateRegex = /]*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 []; + } +} diff --git a/backend/src/services/stockUniverseService.js b/backend/src/services/stockUniverseService.js new file mode 100644 index 0000000..d1faad9 --- /dev/null +++ b/backend/src/services/stockUniverseService.js @@ -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; +} diff --git a/backend/src/services/yahooCrumb.js b/backend/src/services/yahooCrumb.js new file mode 100644 index 0000000..13ec095 --- /dev/null +++ b/backend/src/services/yahooCrumb.js @@ -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(); +} diff --git a/frontend/package.json b/frontend/package.json index b46fe22..a116213 100644 --- a/frontend/package.json +++ b/frontend/package.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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fe12de6..feecfd9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
@@ -46,17 +48,14 @@ export default function App() {
{/* Left: Main Panels */}
- + + + ๐Ÿ“Š Market Analysis + ๐ŸŽฏ Options Flow - - ๐Ÿ”ฅ Phase Classifier - - - ๐Ÿ“Š Daily Analysis - ๐Ÿ” Multi-Signal Scanner @@ -65,18 +64,20 @@ export default function App() { + + + {selectedSymbol && ( + setSelectedSymbol(null)} + /> + )} + + - - - - - - - - @@ -87,15 +88,15 @@ export default function App() {
- {/* Right: Flow Info, Watchlist & Alerts Feed */} + {/* Right: News, Watchlist & Alerts Feed */}
- +
- {/* Bottom: Today's Signals */} + {/* Bottom: Performance Tracking */}
diff --git a/frontend/src/components/charts/FlowHeatmap.jsx b/frontend/src/components/charts/FlowHeatmap.jsx index c8b5a34..0172c54 100644 --- a/frontend/src/components/charts/FlowHeatmap.jsx +++ b/frontend/src/components/charts/FlowHeatmap.jsx @@ -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 ( -
-

- Options Flow Heatmap -

-
- No flow data available -
-
- ); - } - - return ( -
-

- Options Flow Heatmap -

- -
- ๐ŸŸข Bullish Premium - ๐Ÿ”ด Bearish Premium -
-
- ); -} - +export default function() { return null; } diff --git a/frontend/src/components/charts/PriceChart.jsx b/frontend/src/components/charts/PriceChart.jsx index 5e65cda..0172c54 100644 --- a/frontend/src/components/charts/PriceChart.jsx +++ b/frontend/src/components/charts/PriceChart.jsx @@ -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 ( -
-
- Select a symbol to view price chart -
-
- ); - } - - return ( -
- {/* Timeframe Selector */} -
- {['1m', '5m', '15m', '1h', '1D'].map(tf => ( - - ))} -
- - {/* Chart Container */} -
- - {/* Flow Legend */} -
-
-
- Bullish Flow -
-
-
- Bearish Flow -
-
-
- ๐Ÿš€๐Ÿš€๐Ÿš€ High Score -
-
-
- ); -} - -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; } diff --git a/frontend/src/components/dashboard/BacktestPanel.jsx b/frontend/src/components/dashboard/BacktestPanel.jsx index 940290b..b254a60 100644 --- a/frontend/src/components/dashboard/BacktestPanel.jsx +++ b/frontend/src/components/dashboard/BacktestPanel.jsx @@ -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 ( -
-
-
Running backtests...
-
- ); - } + // 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 (
+ {/* Header */}
-

Strategy Backtests (90 Days)

- +

Historical Signal Backtest

+
+
+ + 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]} + /> +
+
+ Min Premium: +
+ $ + { + 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" + /> +
+
+ +
- {results.length === 0 && !loading && ( -
- No backtest results available. Try refreshing or check the backend logs. + {/* Date Selection Info */} +
+

+ ๐Ÿ“… Analyzing signals from {selectedDate} +

+

+ Showing symbols with 2-3 rocket scores that had more than 3 option trades with premium > ${(minPremium / 1000).toFixed(0)}K +

+
+ +
+ {/* Left: Symbols List */} +
+

+ + Symbols with High Signal Activity +

+ + {loading ? ( +
+
+
Loading symbols...
+
+ ) : symbols.length === 0 ? ( +
+ No symbols found for this date. Try a different date. +
+ ) : ( +
+ {symbols.map((symbol) => ( +
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' + }`} + > +
+

{symbol.symbol_norm}

+ 10 ? 'success' : 'warning'} + className="text-xs" + > + {symbol.total_trades || symbol.signal_count || 0} trades + {symbol.signal_periods ? ` (${symbol.signal_periods} periods)` : ''} + +
+ +
+
+ Total Premium: + + ${((Number(symbol.total_premium) || 0) / 1000000).toFixed(2)}M + +
+
+ Avg Rocket: + + {symbol.avg_rocket_score != null ? Number(symbol.avg_rocket_score).toFixed(1) : 'N/A'} + +
+
+ Entry Price: + + {symbol.entry_price != null ? `$${Number(symbol.entry_price).toFixed(2)}` : 'N/A'} + +
+
+ Direction: + + {symbol.directions || 'Mixed'} + +
+
+ + {/* BULL/BEAR Breakdown */} + {(symbol.bull_count > 0 || symbol.bear_count > 0) && ( +
+ {symbol.bull_count > 0 && ( +
+
+ ๐ŸŸข BULL: + {symbol.bull_count} signals +
+
+
+ ${((Number(symbol.bull_premium) || 0) / 1000000).toFixed(2)}M +
+
+ ${((Number(symbol.bull_min_premium) || 0) / 1000).toFixed(0)}K - ${((Number(symbol.bull_max_premium) || 0) / 1000).toFixed(0)}K +
+
+
+ )} + {symbol.bear_count > 0 && ( +
+
+ ๐Ÿ”ด BEAR: + {symbol.bear_count} signals +
+
+
+ ${((Number(symbol.bear_premium) || 0) / 1000000).toFixed(2)}M +
+
+ ${((Number(symbol.bear_min_premium) || 0) / 1000).toFixed(0)}K - ${((Number(symbol.bear_max_premium) || 0) / 1000).toFixed(0)}K +
+
+
+ )} +
+ )} + + {/* Predictive Factors */} +
+
๐Ÿ“Š Predictive Factors:
+ + {/* Moneyness */} + {(symbol.itm_count > 0 || symbol.otm_count > 0) && ( +
+ Moneyness: +
+ {symbol.itm_count || 0} ITM + / + {symbol.otm_count || 0} OTM +
+
+ )} + + {/* DTE */} + {symbol.avg_dte != null && ( +
+ Avg DTE: + + {Number(symbol.avg_dte).toFixed(0)} days + {symbol.most_common_dte_bucket && ( + ({symbol.most_common_dte_bucket}) + )} + +
+ )} + + {/* Session Distribution */} + {(symbol.rth_count > 0 || symbol.pre_count > 0 || symbol.post_count > 0) && ( +
+ Session: +
+ {symbol.rth_count || 0} RTH + {symbol.pre_count > 0 && ( + <> + / + {symbol.pre_count} PRE + + )} + {symbol.post_count > 0 && ( + <> + / + {symbol.post_count} POST + + )} +
+
+ )} + + {/* Catalyst */} + {symbol.catalyst_count > 0 && ( +
+ โšก Catalyst: + + {symbol.catalyst_count} signals ({Number(symbol.pct_with_catalyst || 0).toFixed(0)}%) + +
+ )} + + {/* Volume/OI Ratio */} + {symbol.avg_vol_oi_ratio != null && ( +
+ 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 && ( + + ({symbol.vol_exceeds_oi_count} trades) + + )} + +
+ )} + + {/* Strike Clustering */} + {symbol.unique_strikes > 0 && ( +
+ Strike Clustering: + + {symbol.unique_strikes} unique strikes + {symbol.avg_trades_per_strike > 0 && ( + + ({Number(symbol.avg_trades_per_strike).toFixed(1)} avg/strike) + + )} + {symbol.max_trades_at_single_strike > 1 && ( + + (max: {symbol.max_trades_at_single_strike}) + + )} + +
+ )} + + {/* IV */} + {symbol.avg_iv != null && ( +
+ Avg IV: + + {Number(symbol.avg_iv).toFixed(1)}% + +
+ )} +
+
+ ))} +
+ )}
- )} -
- {results.map((result, idx) => ( -
-
-

{result.name}

- = 60 ? 'success' : parseFloat(result.winRate) >= 50 ? 'warning' : 'destructive'} - className="text-lg px-3 py-1" - > - {result.winRate}% Win Rate - + {/* Right: Performance Analysis */} +
+

+ + Performance Analysis +

+ + {loadingPerformance ? ( +
+
+
Analyzing performance...
- -
-
-
- {result.totalOccurrences || 0} -
-
Total Signals
-
-
-
- {result.avgWin || '0.00'}% -
-
Avg Win
-
-
-
- {result.avgLoss || '0.00'}% -
-
Avg Loss
-
-
-
= 0 ? 'text-purple-400' : 'text-orange-400'}`}> - {result.expectancy || '0.00'}% -
-
Expectancy
-
+ ) : !performance ? ( +
+ Select a symbol to view performance analysis
- -
-
-
- - Best Conditions -
-
    - {result.bestConditions && result.bestConditions.length > 0 ? ( - result.bestConditions.map((cond, i) => ( -
  • - โ€ข - {cond.condition}: - {cond.winRate} - ({cond.occurrences} trades) -
  • - )) + ) : ( +
    + {/* Summary Card */} +
    +
    +

    {performance.symbol}

    + {performance.performance.wouldBeProfitable ? ( + ) : ( -
  • No data available
  • + )} -
-
- -
-
- - Worst Conditions
-
    - {result.worstConditions && result.worstConditions.length > 0 ? ( - result.worstConditions.map((cond, i) => ( -
  • - โ€ข - {cond.condition}: - {cond.winRate} - ({cond.occurrences} trades) -
  • - )) - ) : ( -
  • No data available
  • - )} -
-
-
+ +
+
+ Entry Price: + ${Number(performance.entryPrice || 0).toFixed(2)} +
+
+ Final Change: + = 0 ? 'text-green-400' : 'text-red-400' + }`}> + {Number(performance.performance?.finalGain || 0) >= 0 ? '+' : ''} + {Number(performance.performance?.finalGain || 0).toFixed(2)}% + +
+
+ Max Gain: + + +{Number(performance.performance?.maxGain || 0).toFixed(2)}% + +
+
+ Max Loss: + + {Number(performance.performance?.maxLoss || 0).toFixed(2)}% + +
+
- {result.error && ( -
- โš ๏ธ Error: {result.error} +
+

+ {performance.performance.profitReason} +

+ + {/* Detailed Analysis for Mixed Signals */} + {performance.performance.detailedAnalysis && ( +
+
Mixed Signals Breakdown:
+
+
+ BULL: +
+ + {performance.performance.detailedAnalysis.bullSignals.result} + +

+ {performance.performance.detailedAnalysis.bullSignals.explanation} +

+
+
+
+ BEAR: +
+ + {performance.performance.detailedAnalysis.bearSignals.result} + +

+ {performance.performance.detailedAnalysis.bearSignals.explanation} +

+
+
+
+

+ ๐Ÿ’ก {performance.performance.detailedAnalysis.recommendation} +

+
+
+
+ )} +
- )} -
- ))} + + {/* Price Chart */} + {performance.priceHistory && performance.priceHistory.length > 0 && ( +
+
Price Movement (7 Days)
+
+ {performance.priceHistory.map((day) => ( +
+
+ {new Date(day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +
+
+
= 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' + }} + /> +
+
= 0 ? 'text-green-400' : 'text-red-400' + }`}> + {Number(day.changePct || 0) >= 0 ? '+' : ''}{Number(day.changePct || 0).toFixed(2)}% +
+
+ ${Number(day.close || 0).toFixed(2)} +
+
+ ))} +
+
+ )} + + {/* Signal Details */} + {performance.signals && ( +
+
Signal Details
+
+
+ Signal Count: + + {performance.signals.signal_count || 0} + +
+
+ Total Premium: + + ${((Number(performance.signals.total_premium) || 0) / 1000000).toFixed(2)}M + +
+
+ Max Rocket: + + {performance.signals.max_rocket_score != null ? Number(performance.signals.max_rocket_score).toFixed(1) : 'N/A'} + +
+
+ Direction: + + {performance.signals.directions || 'Mixed'} + +
+
+ + {/* 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) && ( +
+
๐Ÿ“Š Signal Quality Factors:
+ + {/* Moneyness */} + {(performance.signals.itm_count > 0 || performance.signals.otm_count > 0) && ( +
+ Moneyness: +
+ {performance.signals.itm_count || 0} ITM + / + {performance.signals.otm_count || 0} OTM +
+
+ )} + + {/* DTE */} + {performance.signals.avg_dte != null && ( +
+ Avg DTE: + + {Number(performance.signals.avg_dte).toFixed(0)} days + {performance.signals.most_common_dte_bucket && ( + ({performance.signals.most_common_dte_bucket}) + )} + +
+ )} + + {/* Session Distribution */} + {(performance.signals.rth_count > 0 || performance.signals.pre_count > 0 || performance.signals.post_count > 0) && ( +
+ Session: +
+ {performance.signals.rth_count || 0} RTH + {performance.signals.pre_count > 0 && ( + <> + / + {performance.signals.pre_count} PRE + + )} + {performance.signals.post_count > 0 && ( + <> + / + {performance.signals.post_count} POST + + )} +
+
+ )} + + {/* Catalyst */} + {performance.signals.catalyst_count > 0 && ( +
+ โšก Catalyst: + + {performance.signals.catalyst_count} signals ({Number(performance.signals.pct_with_catalyst || 0).toFixed(0)}%) + +
+ )} + + {/* Volume/OI Ratio */} + {performance.signals.avg_vol_oi_ratio != null && ( +
+ 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 && ( + + ({performance.signals.vol_exceeds_oi_count} trades) + + )} + +
+ )} + + {/* Strike Clustering */} + {performance.signals.unique_strikes > 0 && ( +
+ Strike Clustering: + + {performance.signals.unique_strikes} unique strikes + +
+ )} +
+ )} +
+ )} +
+ )} +
); diff --git a/frontend/src/components/dashboard/MarketScreenerPanel.jsx b/frontend/src/components/dashboard/MarketScreenerPanel.jsx new file mode 100644 index 0000000..ad3b56a --- /dev/null +++ b/frontend/src/components/dashboard/MarketScreenerPanel.jsx @@ -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 โ€”; + const positive = value >= 0; + return ( + + {positive ? '+' : ''}{value.toFixed(2)}% + + ); +} + +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 ( + + {tier} + + ); +} + +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 โ‡…; + return {sortDir === 'desc' ? 'โ†“' : 'โ†‘'}; + }; + + return ( +
+ {/* Leaderboard strip */} + {leaderboard && ( +
+ + + +
+ )} + + {/* Main screener card */} +
+ {/* Header */} +
+
+

๐Ÿ“Š Market Screener

+ {lastUpdated && ( + + Updated {lastUpdated.toLocaleTimeString()} + + )} + {loading && Refreshing...} +
+
+ 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" + /> + +
+
+ + {/* Cap tier tabs */} +
+ {CAP_TABS.map(tab => ( + + ))} +
+ + {/* Table */} + {error ? ( +
+

โš ๏ธ {error}

+ +
+ ) : ( +
+ + + + + + + + + + + + + + + {loading && data.length === 0 ? ( + Array.from({ length: 10 }).map((_, i) => ( + + {Array.from({ length: 8 }).map((_, j) => ( + + ))} + + )) + ) : processed.length === 0 ? ( + + ) : ( + processed.map(row => ( + onSelectSymbol?.(row.symbol)} + className="hover:bg-slate-800/40 cursor-pointer transition-colors group" + > + + + + + + + + + + )) + )} + +
SymbolNameSector toggleSort('price')} + >Price toggleSort('change_pct')} + >Change toggleSort('market_cap')} + >Mkt Cap toggleSort('volume')} + >Volume Cap Tier
+
+
No results found
+ + {row.symbol} + + {row.name} + + {row.sector} + + + + {row.price != null ? `$${row.price.toFixed(2)}` : 'โ€”'} + + + + + {fmtMktCap(row.market_cap)} + + {fmtVolume(row.volume)} + + +
+
+ )} + + {/* Footer */} +
+ {processed.length} of {data.length} symbols + Powered by Yahoo Finance โ€ข Free data โ€ข 60s refresh +
+
+
+ ); +} + +function LeaderCard({ title, items = [], type }) { + return ( +
+
{title}
+
+ {items.slice(0, 5).map(item => ( +
+ {item.symbol} + {type === 'volume' ? ( + + {item.volume >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume} + + ) : ( + + )} +
+ ))} + {items.length === 0 &&
Loading...
} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/NewsFeedPanel.jsx b/frontend/src/components/dashboard/NewsFeedPanel.jsx new file mode 100644 index 0000000..8c0e1c6 --- /dev/null +++ b/frontend/src/components/dashboard/NewsFeedPanel.jsx @@ -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 ; + if (sentiment === 'BEARISH') return ; + return ; +} + +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 ( +
+
+

๐Ÿ“ฐ Market News

+ {loading && Loading...} +
+
+ {loading ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ )) + ) : news.length === 0 ? ( +
No news available
+ ) : ( + news.map((item, i) => ( + + +
+
+ {item.title} +
+
{item.source}
+
+
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/OptionsFlowCardList.jsx b/frontend/src/components/dashboard/OptionsFlowCardList.jsx index bdb9e08..75849b1 100644 --- a/frontend/src/components/dashboard/OptionsFlowCardList.jsx +++ b/frontend/src/components/dashboard/OptionsFlowCardList.jsx @@ -291,28 +291,28 @@ export function OptionsFlowCardList({ data, onCardClick, selectedRow, reversalsB {row.stockPrice && row.stockPrice.currentPrice && (
- ${row.stockPrice.currentPrice.toFixed(2)} + ${(typeof row.stockPrice.currentPrice === 'number' ? row.stockPrice.currentPrice : parseFloat(row.stockPrice.currentPrice) || 0).toFixed(2)} - {row.stockPrice.changePercent !== undefined && ( + {row.stockPrice.changePercent !== undefined && row.stockPrice.changePercent !== null && ( = 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)}% )} - {row.stockPrice.change !== undefined && ( + {row.stockPrice.change !== undefined && row.stockPrice.change !== null && ( = 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)}) )}
@@ -446,6 +446,197 @@ export function OptionsFlowCardList({ data, onCardClick, selectedRow, reversalsB
)} + {/* 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) ? ( +
+
๐Ÿ›๏ธ Institutional
+
+ {row.confidence_score !== null && row.confidence_score !== undefined && ( +
+ Confidence +
+
+
= 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)}%` }} + /> +
+ = 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)}% + +
+
+ )} + {row.signal_strength !== null && row.signal_strength !== undefined && ( +
+ Strength +
+
+
= 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)}%` }} + /> +
+ = 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)} + +
+
+ )} + {row.relative_premium_score !== null && row.relative_premium_score !== undefined && ( +
+ Rel. Prem +
+
+
= 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)}%` }} + /> +
+ = 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)} + +
+
+ )} +
+
+ ) : null} + + {/* Phase 3: Dealer & Regime Analytics */} + {(row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined) || + row.market_regime || row.volatility_intent ? ( +
+
๐ŸŽฏ Dealer & Regime
+
+ {row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined && ( +
+ Pressure: + = 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)} + +
+ )} + {row.market_regime && ( + + {row.market_regime === 'TREND' ? '๐Ÿ“ˆ' : row.market_regime === 'RANGE' ? 'โ†”๏ธ' : 'โšก'} {row.market_regime} + + )} + {row.volatility_intent && ( + + {row.volatility_intent === 'LONG_VOL' ? '๐Ÿ“Š' : row.volatility_intent === 'SHORT_VOL' ? '๐Ÿ“‰' : row.volatility_intent === 'DIRECTIONAL' ? 'โžก๏ธ' : '๐Ÿ”„'} {row.volatility_intent.replace('_', '/')} + + )} +
+
+ ) : 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 ? ( +
+
โฑ๏ธ Time-Sequenced
+
+ {row.flow_acceleration !== null && row.flow_acceleration !== undefined && ( +
+ Accel: + 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`} + +
+ )} + {row.time_between_hits !== null && row.time_between_hits !== undefined && ( +
+ Gap: + + {(typeof row.time_between_hits === 'number' ? row.time_between_hits : parseFloat(row.time_between_hits) || 0).toFixed(1)}m + +
+ )} + {row.follow_on_ratio !== null && row.follow_on_ratio !== undefined && ( +
+ Follow: + = 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)}% + +
+ )} + {row.strike_laddering_detected !== null && row.strike_laddering_detected !== undefined && row.strike_laddering_detected && ( +
+ Ladder: + โœ… +
+ )} +
+
+ ) : null} + {/* Signal */} {signal && signal.signal !== 'NEUTRAL' && signal.signal !== 'WAIT' && (
diff --git a/frontend/src/components/dashboard/OptionsFlowPanel.jsx b/frontend/src/components/dashboard/OptionsFlowPanel.jsx index ece4b64..e35283e 100644 --- a/frontend/src/components/dashboard/OptionsFlowPanel.jsx +++ b/frontend/src/components/dashboard/OptionsFlowPanel.jsx @@ -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 && (
- ${stockPrice.currentPrice.toFixed(2)} + ${(typeof stockPrice.currentPrice === 'number' ? stockPrice.currentPrice : parseFloat(stockPrice.currentPrice) || 0).toFixed(2)} - {stockPrice.changePercent !== undefined && ( + {stockPrice.changePercent !== undefined && stockPrice.changePercent !== null && ( = 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)}% )}
@@ -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 ( - 0 ? 'text-green-400' : 'text-red-400'}`}> - {val > 0 ? '+' : ''}{val.toFixed(2)}% + 0 ? 'text-green-400' : 'text-red-400'}`}> + {numVal > 0 ? '+' : ''}{numVal.toFixed(2)}% ); } @@ -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 ( - 0 ? 'text-green-400' : 'text-red-400'}`}> - {val > 0 ? '+' : ''}{val.toFixed(2)}% + 0 ? 'text-green-400' : 'text-red-400'}`}> + {numVal > 0 ? '+' : ''}{numVal.toFixed(2)}% ); } @@ -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 ( - 0 ? 'text-green-400' : 'text-red-400'}`}> - {val > 0 ? '+' : ''}{val.toFixed(2)}% + 0 ? 'text-green-400' : 'text-red-400'}`}> + {numVal > 0 ? '+' : ''}{numVal.toFixed(2)}% ); } @@ -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 ( - 0 ? 'text-green-400' : 'text-red-400'}`}> - {val > 0 ? '+' : ''}{val.toFixed(2)}% + 0 ? 'text-green-400' : 'text-red-400'}`}> + {numVal > 0 ? '+' : ''}{numVal.toFixed(2)}% ); } @@ -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 ( +
+
+
= 70 ? "bg-green-500" : + score >= 50 ? "bg-yellow-500" : + "bg-orange-500" + )} + style={{ width: `${Math.min(100, score)}%` }} + /> +
+ = 70 ? 'success' : score >= 50 ? 'warning' : 'secondary'} + className={cn("text-xs w-12 justify-center", colorClass)} + > + {roundedScore}% + +
+ ); + } + }, + { + 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 ( +
+
+
= 70 ? "bg-purple-500" : + strength >= 50 ? "bg-blue-500" : + "bg-slate-500" + )} + style={{ width: `${Math.min(100, strength)}%` }} + /> +
+ + {roundedStrength} + +
+ ); + } + }, + { + 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 ( +
+
+
= 70 ? "bg-green-500" : + score >= 50 ? "bg-yellow-500" : + "bg-orange-500" + )} + style={{ width: `${Math.min(100, score)}%` }} + /> +
+ + {roundedScore} + +
+ ); + } + }, // 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 (
- 0 ? 'text-green-400' : reaction < 0 ? 'text-red-400' : 'text-slate-400'}`}> - {reaction > 0 ? '+' : ''}{reaction.toFixed(2)}% + 0 ? 'text-green-400' : numReaction < 0 ? 'text-red-400' : 'text-slate-400'}`}> + {numReaction > 0 ? '+' : ''}{numReaction.toFixed(2)}% {ledToMove && ( โœ“ @@ -1234,22 +1388,26 @@ export default function OptionsFlowPanel() { if (!vwap) return No VWAP; 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 (
- {vwapDist > 0 ? '+' : ''}{vwapDist.toFixed(2)}% + {numVwapDist > 0 ? '+' : ''}{numVwapDist.toFixed(2)}% - {vwap && ( - - V: ${vwap.toFixed(2)} + {vwap && !isNaN(numVwap) && ( + + V: ${numVwap.toFixed(2)} )}
@@ -1541,6 +1699,29 @@ export default function OptionsFlowPanel() { {/* View Mode Toggle & Column Selector */}
+ {/* Symbol Filter for Card View */} + {viewMode === 'cards' && ( +
+ + setCardViewSymbolFilter(e.target.value)} + className="w-32 h-8 text-xs" + /> + {cardViewSymbolFilter && ( + + )} +
+ )}
+
+ + {/* Tabs */} +
+ {TABS.map(tab => ( + + ))} +
+ + {/* Content */} +
+ {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ) : ( + <> + {activeTab === 'overview' && } + {activeTab === 'fundamentals' && } + {activeTab === 'technicals' && } + {activeTab === 'holders' && } + {activeTab === 'news' && } + + )} +
+
+ ); +} + +function OverviewTab({ q, f, t }) { + return ( +
+
+
Price Snapshot
+ + + + + + + + + + + +
+
+
+
Key Metrics
+ + + + + + + 0.15 ? 'negative' : undefined} /> + + + + +
+
+
+ ); +} + +function FundamentalsTab({ f }) { + if (!f) return
No fundamental data available
; + 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 ( +
+ {sections.map(section => ( +
+
{section.title}
+ + + {section.metrics.map(m => ( + + ))} + +
+
+ ))} +
+ ); +} + +function TechnicalsTab({ t, q }) { + if (!t) return
Insufficient price history for technical calculations
; + + 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 ( +
+
+
Momentum Indicators
+ + + + + + + + + + + + + + + + + + + +
RSI (14) + {t.rsi_14 != null ? `${t.rsi_14.toFixed(1)}${rsiLabel(t.rsi_14)}` : 'โ€”'} +
MACD Line= 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {t.macd_line != null ? t.macd_line.toFixed(4) : 'โ€”'} +
MACD Signal + {t.macd_signal != null ? t.macd_signal.toFixed(4) : 'โ€”'} +
MACD Histogram= 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {t.macd_histogram != null ? t.macd_histogram.toFixed(4) : 'โ€”'} +
+
+
+
Moving Averages
+ + + + + + + + + + + + + + + + + + + +
SMA 50 + {t.sma_50 != null ? `$${t.sma_50.toFixed(2)}` : 'โ€”'} + {t.above_sma50 != null && ( + + {t.above_sma50 ? 'โ–ฒ Above' : 'โ–ผ Below'} + + )} +
SMA 200 + {t.sma_200 != null ? `$${t.sma_200.toFixed(2)}` : 'โ€”'} + {t.above_sma200 != null && ( + + {t.above_sma200 ? 'โ–ฒ Above' : 'โ–ผ Below'} + + )} +
% from SMA 50= 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {t.pct_from_sma50 != null ? `${t.pct_from_sma50.toFixed(2)}%` : 'โ€”'} +
% from SMA 200= 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {t.pct_from_sma200 != null ? `${t.pct_from_sma200.toFixed(2)}%` : 'โ€”'} +
+
+
Signal Summary
+
+ {t.rsi_14 != null && ( + = 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'} + + )} + {t.macd_histogram != null && ( + 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'} + + )} + {t.above_sma50 != null && ( + + {t.above_sma50 ? '๐ŸŸข Above SMA50' : '๐Ÿ”ด Below SMA50'} + + )} + {t.above_sma200 != null && ( + + {t.above_sma200 ? '๐ŸŸข Above SMA200' : '๐Ÿ”ด Below SMA200'} + + )} +
+
+
+
+ ); +} + +function HoldersTab({ data, loading }) { + if (loading) return
{Array.from({ length: 5 }).map((_, i) =>
)}
; + if (!data) return
Loading holder data...
; + + 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 ( +
+ {/* Institutional summary */} + {inst?.summary && ( +
+ {[ + { 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 => ( +
+
{m.label}
+
{m.value}
+
+ ))} +
+ )} + + {/* Top institutional holders */} + {inst?.holders && inst.holders.length > 0 && ( +
+
๐Ÿ›๏ธ Top Institutional Holders
+
+ + + + + + + + + + + + + {inst.holders.slice(0, 10).map((h, i) => ( + + + + + + + + + ))} + +
Institution% HeldSharesValueQoQ ChangeReported
{h.name} + {h.pct_held != null ? `${(h.pct_held * 100).toFixed(2)}%` : 'โ€”'} + + {h.shares != null ? h.shares.toLocaleString() : 'โ€”'} + + {h.value != null ? fmtLargeLocal(h.value) : 'โ€”'} + + + {h.shares_change != null && ( + 0 ? 'text-emerald-400' : h.shares_change < 0 ? 'text-red-400' : 'text-slate-500'}`}> + {h.shares_change > 0 ? '+' : ''}{h.shares_change?.toLocaleString()} + + )} + {h.date_reported || 'โ€”'}
+
+
+ )} + + {/* Insider activity */} + {ins && ( +
+
+
๐Ÿ‘ค Insider Activity (90d)
+ {ins.insider_summary && ( + + {ins.insider_summary.net_sentiment} ยท {ins.insider_summary.buys_90d}B / {ins.insider_summary.sells_90d}S + + )} +
+ {ins.transactions && ins.transactions.length > 0 ? ( +
+ + + + + + + + + + + + + {ins.transactions.slice(0, 15).map((tx, i) => ( + + + + + + + + + ))} + +
InsiderTitleTypeSharesValueDate
{tx.insider_name}{tx.title} + {tx.direction} + + {tx.shares != null ? tx.shares.toLocaleString() : 'โ€”'} + + {tx.value != null ? fmtLargeLocal(tx.value) : 'โ€”'} + {tx.start_date || 'โ€”'}
+
+ ) : ( +
No recent insider transactions
+ )} +
+ )} +
+ ); +} + +function NewsTab({ items, loading }) { + if (loading) return
{Array.from({ length: 6 }).map((_, i) =>
)}
; + if (!items || items.length === 0) return
No financial news found for this symbol
; + + return ( + + ); +} diff --git a/frontend/src/components/tables/ExpandedRowDetails.jsx b/frontend/src/components/tables/ExpandedRowDetails.jsx index 497b91b..aa361fd 100644 --- a/frontend/src/components/tables/ExpandedRowDetails.jsx +++ b/frontend/src/components/tables/ExpandedRowDetails.jsx @@ -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 (
-
+
{/* Flow Details */} -
-

๐Ÿ”น FLOW DETAILS

- - {/* Flow Trend Visual */} - {flowTrend && ( -
-
- {flowTrend.icon} -
-
- {flowTrend.label} FLOW -
-
- Last activity: - {lastFlowMinutesAgo} minutes ago {hoursAgo > 0 && `(${hoursAgo} ${hoursAgo === 1 ? 'hour' : 'hours'})`} - -
-
- โš ๏ธ {flowTrend.message} -
-
-
-
- )} + -
-
- Net Premium: - 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)`} - -
-
- Premium Breakdown: -
-
${(bullTotal / 1000000).toFixed(2)}M Calls
-
${(bearTotal / 1000000).toFixed(2)}M Puts
-
-
-
- Flow Type: - - {row.cp_norm === 'CALL' ? 'ITM Calls' : 'ITM Puts'} - {row.badgesRaw?.more?.includes('๐Ÿ’Ž') ? ' (๐Ÿ’Ž Real Money)' : ' (Speculation)'} - -
-
- Volume: - {volume.toLocaleString()} contracts -
- {oi > 0 ? ( -
- Open Interest: - - {oi.toLocaleString()} OI {oi >= 12500 ? '(High)' : oi >= 5000 ? '(Medium)' : '(Low)'} - -
- ) : ( -
- Open Interest: - New flow (no OI yet) -
- )} -
-
+ {/* Institutional Analytics - Phase 1 */} + + + {/* Phase 3: Advanced Dealer & Regime Analytics */} + + + {/* Time-Sequenced Metrics - Separate Section */} + {/* Price Context */} -
-

๐Ÿ“Š PRICE CONTEXT

-
-
- Current: - - ${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'} - -
-
- VWAP: - - ${typeof vwap === 'number' ? vwap.toFixed(2) : 'N/A'} - -
-
- RTH Open: - - ${typeof rthOpen === 'number' ? rthOpen.toFixed(2) : 'N/A'} - -
- {Math.abs(pctVsRthOpen) < 0.01 && Math.abs(pctVsPriorClose) < 0.01 ? ( -
- Price Change: - 0.00% (flat since open) -
- ) : ( - <> -
- vs RTH Open: - 0 ? "text-green-400" : "text-red-400" - )}> - {pctVsRthOpen > 0 ? '+' : ''}{typeof pctVsRthOpen === 'number' ? pctVsRthOpen.toFixed(2) : '0.00'}% - -
-
- vs Prior Close: - 0 ? "text-green-400" : "text-red-400" - )}> - {pctVsPriorClose > 0 ? '+' : ''}{typeof pctVsPriorClose === 'number' ? pctVsPriorClose.toFixed(2) : '0.00'}% - -
- - )} -
- Spot Price: - - ${typeof spot === 'number' ? spot.toFixed(2) : spot || 'N/A'} - -
-
- Moneyness: - 0 ? "text-green-400" : moneynessPct < 0 ? "text-red-400" : "text-slate-300" - )}> - {typeof moneynessPct === 'number' ? moneynessPct.toFixed(2) : moneynessPct || '0.00'}% - -
-
- Session: - - - {session} - - -
-
- - {/* Tape Alignment Visual */} - {tapeAlign !== 'โ€”' ? ( -
-
- โœ… -
-
TAPE ALIGNED
-
Price confirming flow direction โ†‘
-
-
-
- ) : ( -
-
-
- โš ๏ธ -
-
NO TAPE ALIGNMENT
-
Price not confirming flow direction
-
-
-
-
Current Price
-
${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}
-
Expected: Upward momentum
-
Reality: Flat/down
-
-
-
- )} -
+ {/* Timing & Dates */}
-

๐Ÿ“… TIMING & DATES

+

๐Ÿ“… TIMING & DATES

Created Date: @@ -387,7 +219,7 @@ export function ExpandedRowDetails({ row }) { {/* Catalyst */}
-

โšก CATALYST

+

โšก CATALYST

{catalyst !== 'None' ? ( <> @@ -490,183 +322,14 @@ export function ExpandedRowDetails({ row }) { )} {/* Phase 1 & AI Analysis */} -
-
-

๐Ÿ” ANALYSIS

-
- {/* Phase 1 Eligibility Check */} - - - {/* AI Analysis */} - {!showAIAnalysis && ( - - )} -
-
- - {showAIAnalysis && ( -
- {aiLoading && ( -
Analyzing with Claude AI...
- )} - - {aiError && ( -
Error: {aiError}
- )} - - {aiAnalysis && aiAnalysis.analysis && ( -
-
-
- - {aiAnalysis.analysis.assessment} - - - {aiAnalysis.analysis.recommendation} - - - {aiAnalysis.analysis.riskLevel} Risk - -
-

{aiAnalysis.analysis.summary}

-
- - {aiAnalysis.analysis.keyFactors && aiAnalysis.analysis.keyFactors.length > 0 && ( -
-
Key Factors:
-
    - {aiAnalysis.analysis.keyFactors.map((factor, idx) => ( -
  • โ€ข {factor}
  • - ))} -
-
- )} - - {aiAnalysis.analysis.reasoning && ( -
-
Reasoning:
-

{aiAnalysis.analysis.reasoning}

-
- )} - -
- Time Horizon: {aiAnalysis.analysis.timeHorizon} - {aiAnalysis.timestamp && ( - โ€ข {new Date(aiAnalysis.timestamp).toLocaleTimeString()} - )} -
-
- )} -
- )} -
+ setShowPhase1Modal(true)} + /> {/* Last 5 Days Volume History - Table Format */} - {row.volumeHistory && row.volumeHistory.length > 0 && ( -
-

๐Ÿ“Š LAST 5 DAYS VOLUME

-
- - - - - - - - - - - - - - {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 ( - - - - - - - - - - ); - })} - -
DateVolumeOpenHighLowCloseVWAP
{dayLabel}{formatVolume(day.volume)}{formatPrice(day.open)}{formatPrice(day.high)}{formatPrice(day.low)}{formatPrice(day.close)}{formatPrice(day.vwap)}
-
-
- )} +
{/* Phase 1 Eligibility Modal */} @@ -678,4 +341,3 @@ export function ExpandedRowDetails({ row }) {
); } - diff --git a/frontend/src/components/tables/expandedRowDetails/AnalysisSection.jsx b/frontend/src/components/tables/expandedRowDetails/AnalysisSection.jsx new file mode 100644 index 0000000..bea697f --- /dev/null +++ b/frontend/src/components/tables/expandedRowDetails/AnalysisSection.jsx @@ -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 ( +
+
+

๐Ÿ” ANALYSIS

+
+ {/* Phase 1 Eligibility Check */} + + + {/* AI Analysis */} + {!showAIAnalysis && ( + + )} +
+
+ + {showAIAnalysis && ( +
+ {aiLoading && ( +
Analyzing with Claude AI...
+ )} + + {aiError && ( +
Error: {aiError}
+ )} + + {aiAnalysis && aiAnalysis.analysis && ( +
+
+
+ + {aiAnalysis.analysis.assessment} + + + {aiAnalysis.analysis.recommendation} + + + {aiAnalysis.analysis.riskLevel} Risk + +
+

{aiAnalysis.analysis.summary}

+
+ + {aiAnalysis.analysis.keyFactors && aiAnalysis.analysis.keyFactors.length > 0 && ( +
+
Key Factors:
+
    + {aiAnalysis.analysis.keyFactors.map((factor, idx) => ( +
  • โ€ข {factor}
  • + ))} +
+
+ )} + + {aiAnalysis.analysis.reasoning && ( +
+
Reasoning:
+

{aiAnalysis.analysis.reasoning}

+
+ )} + +
+ Time Horizon: {aiAnalysis.analysis.timeHorizon} + {aiAnalysis.timestamp && ( + โ€ข {new Date(aiAnalysis.timestamp).toLocaleTimeString()} + )} +
+
+ )} +
+ )} +
+ ); +} + diff --git a/frontend/src/components/tables/expandedRowDetails/DealerRegimeSection.jsx b/frontend/src/components/tables/expandedRowDetails/DealerRegimeSection.jsx new file mode 100644 index 0000000..bb08e91 --- /dev/null +++ b/frontend/src/components/tables/expandedRowDetails/DealerRegimeSection.jsx @@ -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 ( +
+

๐ŸŽฏ DEALER & REGIME ANALYSIS

+
+ {/* Dealer Hedge Pressure */} + {(row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined) && ( +
+
Dealer Hedge Pressure
+
+
+ {Math.round(row.dealer_hedge_pressure_score)} +
+ {row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined && ( +
+
+
= 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)}%` }} + /> +
+
+ {row.dealer_hedge_pressure_score >= 70 ? "High Pressure" : + row.dealer_hedge_pressure_score >= 50 ? "Moderate" : "Low"} +
+
+ )} +
+ {row.net_gamma_exposure_per_symbol !== null && row.net_gamma_exposure_per_symbol !== undefined && ( +
+ 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`} +
+ )} +
+ )} + + {/* Market Regime */} + {row.market_regime && ( +
+
Market Regime
+
+ {row.market_regime === 'TREND' && ( +
+ ๐Ÿ“ˆ +
+
TREND
+
Continuation bias
+
+
+ )} + {row.market_regime === 'RANGE' && ( +
+ โ†”๏ธ +
+
RANGE
+
Fade or vol-sell bias
+
+
+ )} + {row.market_regime === 'HIGH_VOL_EVENT' && ( +
+ โšก +
+
HIGH VOL EVENT
+
Volatility expansion bias
+
+
+ )} +
+
+ )} + + {/* Volatility Intent */} + {row.volatility_intent && ( +
+
Volatility Intent
+
+ {row.volatility_intent === 'LONG_VOL' && ( +
+ ๐Ÿ“Š +
+
LONG VOL
+
Buying volatility
+
+
+ )} + {row.volatility_intent === 'SHORT_VOL' && ( +
+ ๐Ÿ“‰ +
+
SHORT VOL
+
Selling volatility
+
+
+ )} + {row.volatility_intent === 'DIRECTIONAL' && ( +
+ โžก๏ธ +
+
DIRECTIONAL
+
Directional positioning
+
+
+ )} + {row.volatility_intent === 'HEDGE_UNWIND' && ( +
+ ๐Ÿ”„ +
+
HEDGE/UNWIND
+
Hedging or unwinding
+
+
+ )} +
+ {row.delta_exposure !== null && row.delta_exposure !== undefined && ( +
+ Delta Exp: {Math.abs(row.delta_exposure) >= 1e6 + ? `${(row.delta_exposure / 1e6).toFixed(1)}M` + : `${(row.delta_exposure / 1e3).toFixed(0)}K`} +
+ )} +
+ )} +
+ + {/* Additional Dealer Metrics */} + {(row.gamma_flip_proximity !== null || row.gamma_exposure !== null || + row.dealer_pain_level !== null || row.flow_state) && ( +
+ {row.gamma_flip_proximity !== null && row.gamma_flip_proximity !== undefined && ( +
+ Gamma Flip Proximity: + + {row.gamma_flip_proximity.toFixed(2)} + +
+ {row.gamma_flip_proximity > 0.5 ? "Long gamma zone" : + row.gamma_flip_proximity < -0.5 ? "Short gamma zone" : + "Near flip point"} +
+
+ )} + {row.gamma_exposure !== null && row.gamma_exposure !== undefined && ( +
+ Gamma Exposure: + + {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`} + +
+ )} + {row.dealer_pain_level !== null && row.dealer_pain_level !== undefined && ( +
+ Dealer Pain Level: + = 70 ? "text-red-400" : + row.dealer_pain_level >= 50 ? "text-orange-400" : + "text-yellow-400" + )}> + {Math.round(row.dealer_pain_level)} + +
+ )} + {row.flow_state && ( +
+ Flow State: + + {row.flow_state === 'ACTIONABLE' ? 'โœ… ACTIONABLE' : '๐Ÿ“‹ INFORMATIONAL'} + +
+ )} +
+ )} +
+ ); +} + diff --git a/frontend/src/components/tables/expandedRowDetails/FlowDetailsSection.jsx b/frontend/src/components/tables/expandedRowDetails/FlowDetailsSection.jsx new file mode 100644 index 0000000..c265aab --- /dev/null +++ b/frontend/src/components/tables/expandedRowDetails/FlowDetailsSection.jsx @@ -0,0 +1,110 @@ +import { cn } from '@/utils/cn'; + +export function FlowDetailsSection({ + row, + flowTrend, + lastFlowMinutesAgo, + hoursAgo, + netPremium, + bullTotal, + bearTotal, + volume, + oi +}) { + return ( +
+

๐Ÿ”น FLOW DETAILS

+ + {/* Flow Trend Visual */} + {flowTrend && ( +
+
+ {flowTrend.icon} +
+
+ {flowTrend.label} FLOW +
+
+ Last activity: + {lastFlowMinutesAgo} minutes ago {hoursAgo > 0 && `(${hoursAgo} ${hoursAgo === 1 ? 'hour' : 'hours'})`} + +
+
+ โš ๏ธ {flowTrend.message} +
+
+
+
+ )} + +
+
+ Net Premium: + 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)`} + +
+
+ Premium Breakdown: +
+
${(bullTotal / 1000000).toFixed(2)}M Calls
+
${(bearTotal / 1000000).toFixed(2)}M Puts
+
+
+
+ Flow Type: + + {row.cp_norm === 'CALL' ? 'ITM Calls' : 'ITM Puts'} + {row.badgesRaw?.more?.includes('๐Ÿ’Ž') ? ' (๐Ÿ’Ž Real Money)' : ' (Speculation)'} + +
+
+ Volume: + {volume.toLocaleString()} contracts +
+ {oi > 0 ? ( +
+ Open Interest: + + {oi.toLocaleString()} OI {oi >= 12500 ? '(High)' : oi >= 5000 ? '(Medium)' : '(Low)'} + +
+ ) : ( +
+ Open Interest: + New flow (no OI yet) +
+ )} +
+
+ ); +} + diff --git a/frontend/src/components/tables/expandedRowDetails/InstitutionalAnalyticsSection.jsx b/frontend/src/components/tables/expandedRowDetails/InstitutionalAnalyticsSection.jsx new file mode 100644 index 0000000..d1a5f87 --- /dev/null +++ b/frontend/src/components/tables/expandedRowDetails/InstitutionalAnalyticsSection.jsx @@ -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 ( +
+

๐Ÿ›๏ธ INSTITUTIONAL ANALYTICS

+
+ {/* Confidence Score */} + {(row.confidence_score !== null && row.confidence_score !== undefined) && ( +
+
Confidence Score
+
+
+ {Math.round(row.confidence_score)}% +
+ {row.confidence_score !== null && row.confidence_score !== undefined && ( +
+
+
= 70 ? "bg-green-500" : + row.confidence_score >= 50 ? "bg-yellow-500" : + "bg-orange-500" + )} + style={{ width: `${Math.min(100, row.confidence_score)}%` }} + /> +
+
+ {row.confidence_score >= 70 ? "High Confidence" : + row.confidence_score >= 50 ? "Moderate" : "Low"} +
+
+ )} +
+
+ )} + + {/* Signal Strength */} + {(row.signal_strength !== null && row.signal_strength !== undefined) && ( +
+
Signal Strength
+
+
+ {Math.round(row.signal_strength)} +
+ {row.signal_strength !== null && row.signal_strength !== undefined && ( +
+
+
= 70 ? "bg-purple-500" : + row.signal_strength >= 50 ? "bg-blue-500" : + "bg-slate-500" + )} + style={{ width: `${Math.min(100, row.signal_strength)}%` }} + /> +
+
+ {row.signal_strength >= 70 ? "Strong" : + row.signal_strength >= 50 ? "Moderate" : "Weak"} +
+
+ )} +
+
+ )} + + {/* Relative Premium Score */} + {(row.relative_premium_score !== null && row.relative_premium_score !== undefined) && ( +
+
Relative Premium
+
+
+ {Math.round(row.relative_premium_score)} +
+ {row.relative_premium_score !== null && row.relative_premium_score !== undefined && ( +
+
+
= 70 ? "bg-green-500" : + row.relative_premium_score >= 50 ? "bg-yellow-500" : + "bg-orange-500" + )} + style={{ width: `${Math.min(100, row.relative_premium_score)}%` }} + /> +
+
+ {row.relative_premium_score >= 70 ? "Significant" : + row.relative_premium_score >= 50 ? "Moderate" : "Low"} +
+
+ )} +
+ {row.premium_zscore !== null && row.premium_zscore !== undefined && ( +
+ Z-Score: {row.premium_zscore.toFixed(2)} +
+ )} +
+ )} +
+ + {/* Additional context metrics */} + {(row.premium_percentile_intraday !== null || row.aggression_score !== null || + row.size_concentration_score !== null || row.institutional_likelihood !== null) && ( +
+ {row.premium_percentile_intraday !== null && row.premium_percentile_intraday !== undefined && ( +
+ Intraday Percentile: + + {row.premium_percentile_intraday.toFixed(1)}% + +
+ )} + {row.aggression_score !== null && row.aggression_score !== undefined && ( +
+ Aggression: + + {row.aggression_score.toFixed(0)} + +
+ )} + {row.size_concentration_score !== null && row.size_concentration_score !== undefined && ( +
+ Size Concentration: + + {row.size_concentration_score.toFixed(0)} + +
+ )} + {row.institutional_likelihood !== null && row.institutional_likelihood !== undefined && ( +
+ Institutional Likelihood: + + {(row.institutional_likelihood * 100).toFixed(0)}% + +
+ )} +
+ )} +
+ ); +} + diff --git a/frontend/src/components/tables/expandedRowDetails/PriceContextSection.jsx b/frontend/src/components/tables/expandedRowDetails/PriceContextSection.jsx new file mode 100644 index 0000000..a6741c6 --- /dev/null +++ b/frontend/src/components/tables/expandedRowDetails/PriceContextSection.jsx @@ -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 ( +
+

๐Ÿ“Š PRICE CONTEXT

+
+
+ Current: + + ${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'} + +
+
+ VWAP: + + ${typeof vwap === 'number' ? vwap.toFixed(2) : 'N/A'} + +
+
+ RTH Open: + + ${typeof rthOpen === 'number' ? rthOpen.toFixed(2) : 'N/A'} + +
+ {Math.abs(pctVsRthOpen) < 0.01 && Math.abs(pctVsPriorClose) < 0.01 ? ( +
+ Price Change: + 0.00% (flat since open) +
+ ) : ( + <> +
+ vs RTH Open: + 0 ? "text-green-400" : "text-red-400" + )}> + {pctVsRthOpen > 0 ? '+' : ''}{typeof pctVsRthOpen === 'number' ? pctVsRthOpen.toFixed(2) : '0.00'}% + +
+
+ vs Prior Close: + 0 ? "text-green-400" : "text-red-400" + )}> + {pctVsPriorClose > 0 ? '+' : ''}{typeof pctVsPriorClose === 'number' ? pctVsPriorClose.toFixed(2) : '0.00'}% + +
+ + )} +
+ Spot Price: + + ${typeof spot === 'number' ? spot.toFixed(2) : spot || 'N/A'} + +
+
+ Moneyness: + 0 ? "text-green-400" : moneynessPct < 0 ? "text-red-400" : "text-slate-300" + )}> + {typeof moneynessPct === 'number' ? moneynessPct.toFixed(2) : moneynessPct || '0.00'}% + +
+
+ Session: + + + {session} + + +
+
+ + {/* Tape Alignment Visual */} + {tapeAlign !== 'โ€”' ? ( +
+
+ โœ… +
+
TAPE ALIGNED
+
Price confirming flow direction โ†‘
+
+
+
+ ) : ( +
+
+
+ โš ๏ธ +
+
NO TAPE ALIGNMENT
+
Price not confirming flow direction
+
+
+
+
Current Price
+
${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}
+
Expected: Upward momentum
+
Reality: Flat/down
+
+
+
+ )} +
+ ); +} + diff --git a/frontend/src/components/tables/expandedRowDetails/TimeSequencedSection.jsx b/frontend/src/components/tables/expandedRowDetails/TimeSequencedSection.jsx new file mode 100644 index 0000000..8830a0e --- /dev/null +++ b/frontend/src/components/tables/expandedRowDetails/TimeSequencedSection.jsx @@ -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 ( +
+

โฑ๏ธ TIME-SEQUENCED METRICS

+
+
+ {row.flow_acceleration !== null && row.flow_acceleration !== undefined && ( +
+ 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`} + +
+ {row.flow_acceleration > 0 ? "Escalating" : "Decelerating"} +
+
+ )} + {row.time_between_hits !== null && row.time_between_hits !== undefined && ( +
+ Time Between Hits: + + {row.time_between_hits.toFixed(1)} min + +
+ )} + {row.follow_on_ratio !== null && row.follow_on_ratio !== undefined && ( +
+ 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)}% + +
+ {row.follow_on_ratio >= 0.7 ? "Continuation" : + row.follow_on_ratio >= 0.4 ? "Mixed" : "Reversal"} +
+
+ )} + {row.strike_laddering_detected !== null && row.strike_laddering_detected !== undefined && ( +
+ Strike Laddering: + + {row.strike_laddering_detected ? 'โœ… Detected' : 'โŒ None'} + +
+ )} +
+
+
+ ); +} + diff --git a/frontend/src/components/tables/expandedRowDetails/VolumeHistorySection.jsx b/frontend/src/components/tables/expandedRowDetails/VolumeHistorySection.jsx new file mode 100644 index 0000000..69a37ff --- /dev/null +++ b/frontend/src/components/tables/expandedRowDetails/VolumeHistorySection.jsx @@ -0,0 +1,73 @@ +export function VolumeHistorySection({ row }) { + if (!row.volumeHistory || row.volumeHistory.length === 0) return null; + + return ( +
+

๐Ÿ“Š LAST 5 DAYS VOLUME

+
+ + + + + + + + + + + + + + {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 ( + + + + + + + + + + ); + })} + +
DateVolumeOpenHighLowCloseVWAP
{dayLabel}{formatVolume(day.volume)}{formatPrice(day.open)}{formatPrice(day.high)}{formatPrice(day.low)}{formatPrice(day.close)}{formatPrice(day.vwap)}
+
+
+ ); +} + diff --git a/k8s/app.yaml b/k8s/app.yaml new file mode 100644 index 0000000..8bebd1a --- /dev/null +++ b/k8s/app.yaml @@ -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 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..e32287d --- /dev/null +++ b/k8s/ingress.yaml @@ -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