Deploy to Talos K8s: add Market Analysis and Docker/K8s manifests
This commit is contained in:
parent
7ec16dc655
commit
4774890415
|
|
@ -0,0 +1,23 @@
|
||||||
|
name: Build Institutional Trader
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DOCKER_HOST: tcp://172.17.0.1:2375
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build and push (Kaniko)
|
||||||
|
run: |
|
||||||
|
JOB_CONTAINER=$(docker ps --format '{{.Names}}' | grep 'GITEA-ACTIONS-TASK' | head -1)
|
||||||
|
docker run --rm \
|
||||||
|
--volumes-from "$JOB_CONTAINER" \
|
||||||
|
gcr.io/kaniko-project/executor:latest \
|
||||||
|
--context=dir://"$GITHUB_WORKSPACE" \
|
||||||
|
--dockerfile="$GITHUB_WORKSPACE/Dockerfile" \
|
||||||
|
--destination=192.168.8.250:5000/market:latest \
|
||||||
|
--insecure \
|
||||||
|
--skip-tls-verify
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Stage 1: Build Frontend
|
||||||
|
FROM node:18-alpine AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Backend & Serve
|
||||||
|
FROM node:18-alpine
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
# Install backend dependencies
|
||||||
|
COPY backend/package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Copy backend source
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
# Copy built frontend to the public directory expected by server.js
|
||||||
|
# (__dirname is /app/backend/src, so ../../public is /app/public)
|
||||||
|
COPY --from=frontend-build /app/frontend/dist /app/public
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3010
|
||||||
|
|
||||||
|
EXPOSE 3010
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
|
|
@ -58,7 +58,14 @@ USE_PYTHON_SERVICE=true
|
||||||
### Terminal 1: Python Service
|
### Terminal 1: Python Service
|
||||||
```bash
|
```bash
|
||||||
cd backend/python_service
|
cd backend/python_service
|
||||||
source venv/bin/activate # or venv\Scripts\activate on Windows
|
# Activate virtual environment:
|
||||||
|
# Git Bash on Windows:
|
||||||
|
source venv/Scripts/activate
|
||||||
|
# PowerShell/CMD on Windows:
|
||||||
|
venv\Scripts\activate
|
||||||
|
# Linux/macOS:
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
uvicorn main:app --reload --port 8010
|
uvicorn main:app --reload --port 8010
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2",
|
||||||
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
|
|
@ -1499,6 +1500,15 @@
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
|
@ -1895,6 +1905,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml2js": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": ">=0.6.0",
|
||||||
|
"xmlbuilder": "~11.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2",
|
||||||
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"CreatedDate": "2025-01-15",
|
||||||
|
"CreatedTime": "10:30:45 AM",
|
||||||
|
"Symbol": "(5🟩) AAPL · RTH ⚡ 🟢💎⭐💰 ⚡ 🔥",
|
||||||
|
"Rocket": "🚀🚀🚀 [ITM 2.5%]",
|
||||||
|
"NetPremium": "1.50 M",
|
||||||
|
"Premium": "850.00 K",
|
||||||
|
|
||||||
|
"premium_num": 850000,
|
||||||
|
"bull_total": 1500000,
|
||||||
|
"bear_total": 50000,
|
||||||
|
|
||||||
|
"PctVsPriorClose": 1.2,
|
||||||
|
"PctVsRthOpen": 0.8,
|
||||||
|
"Pct5m": 0.3,
|
||||||
|
"Pct15m": 0.5,
|
||||||
|
"TapeAlign": "↗︎",
|
||||||
|
"NearAlert": "EARNINGS",
|
||||||
|
|
||||||
|
"ExpirationDate": "2025-01-17",
|
||||||
|
"Price": "185.50",
|
||||||
|
"CallPut": "CALL",
|
||||||
|
"Side": "AA",
|
||||||
|
"Strike": "185",
|
||||||
|
"Spot": "185.50",
|
||||||
|
"Volume": "5000",
|
||||||
|
"OI": "3000",
|
||||||
|
|
||||||
|
"direction": "BULL",
|
||||||
|
"badge_round": "🟢",
|
||||||
|
"badge_more": "💎⭐💰✔",
|
||||||
|
"flash": "⚡",
|
||||||
|
"rocket_score": 5.2,
|
||||||
|
|
||||||
|
"session_bucket": "RTH",
|
||||||
|
"flow_ts_utc": "2025-01-15T16:30:45+00:00",
|
||||||
|
"mny_pct": 2.5,
|
||||||
|
|
||||||
|
"vwap_at_signal": 185.20,
|
||||||
|
"price_vs_vwap_pct": 0.16,
|
||||||
|
|
||||||
|
"signal_tier": "TIER_1",
|
||||||
|
"checklist_score": 8.5,
|
||||||
|
"checklist_passed": true,
|
||||||
|
|
||||||
|
"premium_zscore": 2.3,
|
||||||
|
"premium_percentile_intraday": 85.5,
|
||||||
|
"relative_premium_score": 72.5,
|
||||||
|
|
||||||
|
"aggression_score": 68.0,
|
||||||
|
"size_concentration_score": 75.0,
|
||||||
|
"repeat_trade_velocity": 55.0,
|
||||||
|
"strike_clustering_score": 60.0,
|
||||||
|
"signal_strength": 64.7,
|
||||||
|
|
||||||
|
"early_noise_reject": false,
|
||||||
|
|
||||||
|
"flow_acceleration": 12500.5,
|
||||||
|
"time_between_hits": 3.2,
|
||||||
|
"follow_on_ratio": 0.75,
|
||||||
|
"strike_laddering_detected": true,
|
||||||
|
|
||||||
|
"delta_exposure": 92500000,
|
||||||
|
"gamma_exposure": 1250000000,
|
||||||
|
"volatility_intent": "LONG_VOL",
|
||||||
|
|
||||||
|
"net_gamma_exposure_per_symbol": 8500000000,
|
||||||
|
"gamma_flip_proximity": 0.15,
|
||||||
|
"dealer_hedge_pressure_score": 72.5,
|
||||||
|
|
||||||
|
"market_regime": "TREND",
|
||||||
|
"flow_state": "ACTIONABLE",
|
||||||
|
|
||||||
|
"confidence_score": 78.5,
|
||||||
|
"institutional_likelihood": 0.85,
|
||||||
|
"dealer_pain_level": 65.0,
|
||||||
|
"expected_move_vs_implied": 1.35
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"timestamp": "2025-01-15T16:35:00.123456"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
# Institutional-Grade Options Flow Analytics
|
||||||
|
|
||||||
|
This document describes the institutional-grade enhancements to the options flow pipeline.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The pipeline has been refactored to convert static retail-style flow detection into dynamic, dealer-aware, time-sequenced signals suitable for intraday momentum and 1-5 day swing trades.
|
||||||
|
|
||||||
|
## New Analytics Modules
|
||||||
|
|
||||||
|
### 1. Relative Premium Scoring (`relative_premium_scorer.py`)
|
||||||
|
|
||||||
|
**Purpose**: Replace static premium filter (minPremium = $80K) with context-aware relative scoring.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `premium_zscore`: Z-score of premium relative to 20-day rolling window per ticker
|
||||||
|
- `premium_percentile_intraday`: Percentile rank within same-day flow
|
||||||
|
- `relative_premium_score`: Composite score (0-100) combining z-score, intraday percentile, and median normalization
|
||||||
|
|
||||||
|
**Usage**: Premium of $80K might be significant for AAPL but noise for TSLA. This module computes relative significance.
|
||||||
|
|
||||||
|
### 2. Signal Component Scoring (`signal_component_scorer.py`)
|
||||||
|
|
||||||
|
**Purpose**: Convert binary badge logic (💎 ⭐ 🟢 🔴) into continuous numeric signal components.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `aggression_score`: Measures trade aggression (ITM premiums, ask-side trades)
|
||||||
|
- `size_concentration_score`: Measures size concentration (single large trade vs many small ones)
|
||||||
|
- `repeat_trade_velocity`: Measures repeat trade frequency (urgency building)
|
||||||
|
- `strike_clustering_score`: Measures strike clustering (laddering patterns)
|
||||||
|
- `signal_strength`: Composite score = 0.30 * aggression + 0.30 * size_concentration + 0.20 * repeat_velocity + 0.20 * strike_clustering
|
||||||
|
|
||||||
|
**Note**: Badges remain display-only. Signal strength is computed from components.
|
||||||
|
|
||||||
|
### 3. Tier-0 Noise Rejection (`noise_rejector.py`)
|
||||||
|
|
||||||
|
**Purpose**: Reject low-quality signals before enrichment to reduce processing overhead.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `early_noise_reject`: Boolean flag indicating if signal should be rejected as noise
|
||||||
|
|
||||||
|
**Rejection Criteria**:
|
||||||
|
- Single isolated trade (no repeat activity within 30 minutes)
|
||||||
|
- Far OTM weekly lottos (>15% OTM with <7 days to expiry)
|
||||||
|
- Delta-adjusted premium below threshold (<$50K)
|
||||||
|
|
||||||
|
### 4. Time-Sequenced Flow Analysis (`time_sequenced_analyzer.py`)
|
||||||
|
|
||||||
|
**Purpose**: Analyze flow patterns over time to detect urgency, distribution, and continuation.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `flow_acceleration`: Change in premium per minute (Δ premium / minute)
|
||||||
|
- `time_between_hits`: Average time between consecutive trades (minutes)
|
||||||
|
- `follow_on_ratio`: Fraction of trades in same direction after initial trade (0-1)
|
||||||
|
- `strike_laddering_detected`: Boolean indicating sequential strike accumulation
|
||||||
|
|
||||||
|
**Interpretation**:
|
||||||
|
- Escalating premium + decreasing time gaps = urgency
|
||||||
|
- Flat premium + widening gaps = distribution
|
||||||
|
|
||||||
|
### 5. Intent Classification (`intent_classifier.py`)
|
||||||
|
|
||||||
|
**Purpose**: Replace naive direction (BULL/BEAR) with nuanced volatility and hedging intent.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `delta_exposure`: Delta exposure (contracts * delta * 100 * spot_price)
|
||||||
|
- `gamma_exposure`: Gamma exposure (contracts * gamma * 100 * spot_price^2)
|
||||||
|
- `volatility_intent`: Enum (LONG_VOL, SHORT_VOL, DIRECTIONAL, HEDGE_UNWIND)
|
||||||
|
|
||||||
|
**Note**: Direction (BULL/BEAR) becomes secondary metadata.
|
||||||
|
|
||||||
|
### 6. Dealer-Aware Flow Context (`dealer_flow_context.py`)
|
||||||
|
|
||||||
|
**Purpose**: Track dealer hedging pressure and gamma exposure.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `net_gamma_exposure_per_symbol`: Sum of gamma exposures for symbol (positive = long gamma, negative = short gamma)
|
||||||
|
- `gamma_flip_proximity`: Proximity to gamma flip point (-1 to 1)
|
||||||
|
- `dealer_hedge_pressure_score`: Dealer hedge pressure score (0-100)
|
||||||
|
|
||||||
|
**Usage**: Validates flow continuation, flow reversals, and gamma squeeze setups.
|
||||||
|
|
||||||
|
### 7. Market Regime Detection (`market_regime_detector.py`)
|
||||||
|
|
||||||
|
**Purpose**: Identify market regime to gate trade signal generation.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `market_regime`: Enum (TREND, RANGE, HIGH_VOL_EVENT)
|
||||||
|
|
||||||
|
**Trade Signal Gating**:
|
||||||
|
- Trend → continuation bias
|
||||||
|
- Range → fade or vol-sell bias
|
||||||
|
- Event → volatility expansion bias
|
||||||
|
|
||||||
|
### 8. Flow Decay & Reversal Validation (`flow_decay_validator.py`)
|
||||||
|
|
||||||
|
**Purpose**: Validate flow decay/reversal signals with anchors.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `flow_state`: Enum (ACTIONABLE, INFORMATIONAL)
|
||||||
|
|
||||||
|
**Validation Criteria**: Flow decay/reversal is actionable ONLY IF:
|
||||||
|
- Premium contracts (relative_premium_score >= 60)
|
||||||
|
- Dealer hedge pressure decreases
|
||||||
|
- Price fails near VWAP / opening range / key level
|
||||||
|
- Otherwise marked as INFORMATIONAL
|
||||||
|
|
||||||
|
### 9. Institutional Confidence Metrics (`institutional_confidence.py`)
|
||||||
|
|
||||||
|
**Purpose**: Calculate confidence scores for institutional flow signals.
|
||||||
|
|
||||||
|
**New Fields**:
|
||||||
|
- `confidence_score`: Overall confidence score (0-100)
|
||||||
|
- `institutional_likelihood`: Likelihood flow is institutional (0-1)
|
||||||
|
- `dealer_pain_level`: Dealer pain level (0-100)
|
||||||
|
- `expected_move_vs_implied`: Expected move vs implied move ratio
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
All modules are integrated into the main processing pipeline in `main.py`:
|
||||||
|
|
||||||
|
1. Basic flow processing (normalization, badges, rocket score)
|
||||||
|
2. Price context enrichment
|
||||||
|
3. Alert matching
|
||||||
|
4. **Institutional analytics pipeline** (NEW):
|
||||||
|
- Tier-0 noise rejection
|
||||||
|
- Relative premium scoring
|
||||||
|
- Signal component scoring
|
||||||
|
- Time-sequenced analysis
|
||||||
|
- Intent classification
|
||||||
|
- Dealer flow context
|
||||||
|
- Market regime detection
|
||||||
|
- Flow decay validation
|
||||||
|
- Confidence metrics
|
||||||
|
5. Filtering (premium, relative premium, badges, direction)
|
||||||
|
6. Output formatting
|
||||||
|
|
||||||
|
## Filtering Changes
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
- Static premium filter: `premium_num > 80000`
|
||||||
|
- Badge requirements: 🟢/🔴 + 💎 + ⭐
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
- Static premium filter: `premium_num > min_premium` (still applied)
|
||||||
|
- **Relative premium filter**: `relative_premium_score >= 60.0` (NEW)
|
||||||
|
- **Noise rejection filter**: `early_noise_reject == False` (NEW)
|
||||||
|
- Badge requirements: 🟢/🔴 + 💎 + ⭐ (still applied, but badges are now display-only)
|
||||||
|
|
||||||
|
## API Response
|
||||||
|
|
||||||
|
All new fields are included in the API response. The response maintains backward compatibility - existing fields remain unchanged, new fields are additive.
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
1. **Flow represents pressure, not prediction**: Signals indicate who is forced to act next (dealers hedging)
|
||||||
|
2. **Institutions trade urgency and forced hedging**: Focus on dealer pain and gamma exposure
|
||||||
|
3. **Fewer, higher-quality signals > more alerts**: Noise rejection and relative premium filtering reduce false positives
|
||||||
|
4. **Every signal must answer**: "Who is forced to act next?"
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
If implemented correctly:
|
||||||
|
- Signal count decreases (noise filtered out)
|
||||||
|
- Average signal quality increases (relative premium, signal strength)
|
||||||
|
- False positives reduce (noise rejection, dealer context validation)
|
||||||
|
- Trades align with intraday momentum and short-term swing horizons (time-sequenced analysis)
|
||||||
|
|
||||||
Binary file not shown.
|
|
@ -192,6 +192,63 @@ async def get_options_flow(
|
||||||
# Recalculate rocket score with price context and alerts
|
# Recalculate rocket score with price context and alerts
|
||||||
df_final = processor.process_rocket_score(df_final)
|
df_final = processor.process_rocket_score(df_final)
|
||||||
|
|
||||||
|
# Apply institutional-grade analytics pipeline
|
||||||
|
logger.info("🔹 Applying institutional-grade analytics...")
|
||||||
|
from services.relative_premium_scorer import RelativePremiumScorer
|
||||||
|
from services.noise_rejector import NoiseRejector
|
||||||
|
from services.signal_component_scorer import SignalComponentScorer
|
||||||
|
from services.time_sequenced_analyzer import TimeSequencedAnalyzer
|
||||||
|
from services.intent_classifier import IntentClassifier
|
||||||
|
from services.dealer_flow_context import DealerFlowContext
|
||||||
|
from services.market_regime_detector import MarketRegimeDetector
|
||||||
|
from services.flow_decay_validator import FlowDecayValidator
|
||||||
|
from services.institutional_confidence import InstitutionalConfidence
|
||||||
|
|
||||||
|
# 1. Tier-0 Noise Rejection (mark but don't filter yet - filtering happens later)
|
||||||
|
logger.info("1️⃣ Applying tier-0 noise rejection...")
|
||||||
|
noise_rejector = NoiseRejector()
|
||||||
|
df_final = noise_rejector.mark_noise_rejections(df_final)
|
||||||
|
|
||||||
|
# 2. Relative Premium Scoring
|
||||||
|
logger.info("2️⃣ Calculating relative premium scores...")
|
||||||
|
premium_scorer = RelativePremiumScorer(pool)
|
||||||
|
df_final = await premium_scorer.enrich_with_relative_premium(df_final)
|
||||||
|
|
||||||
|
# 3. Signal Component Scoring (convert badges to continuous scores)
|
||||||
|
logger.info("3️⃣ Converting badges to continuous signal components...")
|
||||||
|
signal_scorer = SignalComponentScorer()
|
||||||
|
df_final = signal_scorer.enrich_with_signal_components(df_final)
|
||||||
|
|
||||||
|
# 4. Time-Sequenced Flow Analysis
|
||||||
|
logger.info("4️⃣ Analyzing time-sequenced flow patterns...")
|
||||||
|
time_analyzer = TimeSequencedAnalyzer()
|
||||||
|
df_final = time_analyzer.enrich_with_time_sequenced_metrics(df_final)
|
||||||
|
|
||||||
|
# 5. Intent Classification
|
||||||
|
logger.info("5️⃣ Classifying volatility and hedging intent...")
|
||||||
|
intent_classifier = IntentClassifier()
|
||||||
|
df_final = intent_classifier.enrich_with_intent_classification(df_final)
|
||||||
|
|
||||||
|
# 6. Dealer-Aware Flow Context
|
||||||
|
logger.info("6️⃣ Analyzing dealer hedging pressure...")
|
||||||
|
dealer_context = DealerFlowContext()
|
||||||
|
df_final = dealer_context.enrich_with_dealer_context(df_final)
|
||||||
|
|
||||||
|
# 7. Market Regime Detection
|
||||||
|
logger.info("7️⃣ Detecting market regime...")
|
||||||
|
regime_detector = MarketRegimeDetector()
|
||||||
|
df_final = regime_detector.enrich_with_market_regime(df_final)
|
||||||
|
|
||||||
|
# 8. Flow Decay & Reversal Validation
|
||||||
|
logger.info("8️⃣ Validating flow decay and reversal signals...")
|
||||||
|
flow_validator = FlowDecayValidator()
|
||||||
|
df_final = flow_validator.enrich_with_flow_state(df_final)
|
||||||
|
|
||||||
|
# 9. Institutional Confidence Metrics
|
||||||
|
logger.info("9️⃣ Calculating institutional confidence metrics...")
|
||||||
|
confidence_calc = InstitutionalConfidence()
|
||||||
|
df_final = confidence_calc.enrich_with_confidence_metrics(df_final)
|
||||||
|
|
||||||
# Phase 1 Enhancements (BEFORE filtering so all signals get Phase 1 data):
|
# Phase 1 Enhancements (BEFORE filtering so all signals get Phase 1 data):
|
||||||
# Initialize Phase 1 columns with None/empty values first
|
# Initialize Phase 1 columns with None/empty values first
|
||||||
if not df_final.empty:
|
if not df_final.empty:
|
||||||
|
|
@ -259,6 +316,13 @@ async def get_options_flow(
|
||||||
# Filter by minimum premium and badge requirements (matching SQL WHERE clause)
|
# Filter by minimum premium and badge requirements (matching SQL WHERE clause)
|
||||||
logger.info(f"📊 Before filtering: {len(df_final)} rows")
|
logger.info(f"📊 Before filtering: {len(df_final)} rows")
|
||||||
|
|
||||||
|
# Apply noise rejection filter first (exclude early_noise_reject = True)
|
||||||
|
if 'early_noise_reject' in df_final.columns:
|
||||||
|
before_noise = len(df_final)
|
||||||
|
df_final = df_final[~df_final['early_noise_reject']].copy()
|
||||||
|
after_noise = len(df_final)
|
||||||
|
logger.info(f"📊 After noise rejection filter: {after_noise} rows (removed {before_noise - after_noise})")
|
||||||
|
|
||||||
# Only filter if columns exist
|
# Only filter if columns exist
|
||||||
if 'premium_num' in df_final.columns:
|
if 'premium_num' in df_final.columns:
|
||||||
before_premium = len(df_final)
|
before_premium = len(df_final)
|
||||||
|
|
@ -268,6 +332,14 @@ async def get_options_flow(
|
||||||
else:
|
else:
|
||||||
logger.warning("⚠️ premium_num column not found, skipping premium filter")
|
logger.warning("⚠️ premium_num column not found, skipping premium filter")
|
||||||
|
|
||||||
|
# Apply relative premium filter if available
|
||||||
|
if 'relative_premium_score' in df_final.columns:
|
||||||
|
before_relative = len(df_final)
|
||||||
|
min_relative_threshold = 60.0 # Configurable threshold
|
||||||
|
df_final = df_final[df_final['relative_premium_score'] >= min_relative_threshold].copy()
|
||||||
|
after_relative = len(df_final)
|
||||||
|
logger.info(f"📊 After relative premium filter (>={min_relative_threshold}): {after_relative} rows (removed {before_relative - after_relative})")
|
||||||
|
|
||||||
if df_final.empty:
|
if df_final.empty:
|
||||||
logger.warning("⚠️ No data after premium filter")
|
logger.warning("⚠️ No data after premium filter")
|
||||||
return OptionsFlowResponse(
|
return OptionsFlowResponse(
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""
|
||||||
|
Dealer-Aware Flow Context Service
|
||||||
|
Tracks dealer hedging pressure, gamma exposure, and flow continuation patterns
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from datetime import timedelta
|
||||||
|
from utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DealerFlowContext:
|
||||||
|
"""
|
||||||
|
Analyzes flow from dealer perspective:
|
||||||
|
- Net gamma exposure per symbol
|
||||||
|
- Gamma flip proximity (when dealers become long/short gamma)
|
||||||
|
- Dealer hedge pressure (forced hedging activity)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Configuration
|
||||||
|
self.analysis_window_minutes = 120 # Look back window for gamma tracking
|
||||||
|
self.gamma_flip_threshold = 0.3 # Gamma flip when net gamma crosses threshold
|
||||||
|
|
||||||
|
def calculate_net_gamma_exposure_per_symbol(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
symbol: str,
|
||||||
|
timestamp: pd.Timestamp
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate net gamma exposure for a symbol at a given time.
|
||||||
|
Sums all gamma exposures from recent flow.
|
||||||
|
|
||||||
|
Positive = dealers are long gamma (hedging by selling on rallies, buying on dips)
|
||||||
|
Negative = dealers are short gamma (hedging by buying on rallies, selling on dips)
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Look at flow up to this timestamp
|
||||||
|
window_start = timestamp - timedelta(minutes=self.analysis_window_minutes)
|
||||||
|
|
||||||
|
mask = (
|
||||||
|
(df['symbol_norm'] == symbol.upper()) &
|
||||||
|
(df['flow_ts_utc'] >= window_start) &
|
||||||
|
(df['flow_ts_utc'] <= timestamp) &
|
||||||
|
(df['gamma_exposure'].notna())
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_flow = df[mask]
|
||||||
|
|
||||||
|
if recent_flow.empty:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Sum gamma exposures
|
||||||
|
net_gamma = recent_flow['gamma_exposure'].sum()
|
||||||
|
|
||||||
|
return float(net_gamma)
|
||||||
|
|
||||||
|
def calculate_gamma_flip_proximity(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate proximity to gamma flip (when dealers switch from long to short gamma or vice versa).
|
||||||
|
|
||||||
|
Returns: -1.0 to 1.0
|
||||||
|
- Positive = approaching long gamma (dealers becoming volatility buyers)
|
||||||
|
- Negative = approaching short gamma (dealers becoming volatility sellers)
|
||||||
|
- 0.0 = near flip point
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
gamma_exposure = current_row.get('gamma_exposure', 0) or 0
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(flow_ts_utc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate current net gamma
|
||||||
|
net_gamma = self.calculate_net_gamma_exposure_per_symbol(df, symbol, flow_ts_utc)
|
||||||
|
|
||||||
|
# Normalize to -1 to 1 scale
|
||||||
|
# Use exponential scaling to emphasize near-flip conditions
|
||||||
|
if net_gamma == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Simple normalization: divide by a large threshold
|
||||||
|
# More sophisticated: use percentile or adaptive scaling
|
||||||
|
threshold = 1000000000 # 1B in gamma exposure as normalization factor
|
||||||
|
normalized = net_gamma / threshold
|
||||||
|
|
||||||
|
# Clamp to -1 to 1
|
||||||
|
normalized = max(-1.0, min(1.0, normalized))
|
||||||
|
|
||||||
|
# Invert: negative normalized = positive proximity (approaching long gamma)
|
||||||
|
# This is because dealer short gamma (negative) means they're selling volatility
|
||||||
|
return float(-normalized)
|
||||||
|
|
||||||
|
def calculate_dealer_hedge_pressure_score(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate dealer hedge pressure score (0-100).
|
||||||
|
|
||||||
|
Higher score = more forced hedging by dealers:
|
||||||
|
- High net gamma exposure (dealers must hedge)
|
||||||
|
- Recent flow creating gamma imbalance
|
||||||
|
- Flow continuation (dealers hedging creates more flow)
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
gamma_exposure = current_row.get('gamma_exposure', 0) or 0
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(flow_ts_utc):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Net gamma component (0-40 points)
|
||||||
|
# High absolute net gamma = more hedge pressure
|
||||||
|
net_gamma = abs(self.calculate_net_gamma_exposure_per_symbol(df, symbol, flow_ts_utc))
|
||||||
|
if net_gamma > 0:
|
||||||
|
# Normalize: 500M = 20 points, 1B = 40 points
|
||||||
|
normalized_gamma = min(1.0, net_gamma / 1000000000) # 1B threshold
|
||||||
|
score += normalized_gamma * 40.0
|
||||||
|
|
||||||
|
# Recent gamma accumulation (0-30 points)
|
||||||
|
# Recent flow creating gamma imbalance
|
||||||
|
window_start = flow_ts_utc - timedelta(minutes=30)
|
||||||
|
mask = (
|
||||||
|
(df['symbol_norm'] == symbol.upper()) &
|
||||||
|
(df['flow_ts_utc'] >= window_start) &
|
||||||
|
(df['flow_ts_utc'] <= flow_ts_utc) &
|
||||||
|
(df['gamma_exposure'].notna())
|
||||||
|
)
|
||||||
|
recent_gamma = df[mask]['gamma_exposure'].sum()
|
||||||
|
|
||||||
|
if abs(recent_gamma) > 0:
|
||||||
|
# Normalize recent gamma accumulation
|
||||||
|
normalized_recent = min(1.0, abs(recent_gamma) / 500000000) # 500M threshold
|
||||||
|
score += normalized_recent * 30.0
|
||||||
|
|
||||||
|
# Flow continuation component (0-30 points)
|
||||||
|
# If flow is continuing in same direction, dealers are likely hedging
|
||||||
|
follow_on_ratio = current_row.get('follow_on_ratio')
|
||||||
|
if follow_on_ratio is not None and not pd.isna(follow_on_ratio):
|
||||||
|
# High follow-on ratio = continuation = hedge pressure
|
||||||
|
score += follow_on_ratio * 30.0
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, score))
|
||||||
|
|
||||||
|
def validate_flow_continuation(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Validate if flow continuation is likely based on dealer hedge pressure.
|
||||||
|
Returns True if continuation is expected (dealers forced to hedge).
|
||||||
|
"""
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
dealer_pressure = current_row.get('dealer_hedge_pressure_score', 0) or 0
|
||||||
|
net_gamma = current_row.get('net_gamma_exposure_per_symbol', 0) or 0
|
||||||
|
|
||||||
|
# High dealer pressure + significant gamma exposure = continuation likely
|
||||||
|
if dealer_pressure > 50.0 and abs(net_gamma) > 100000000: # 100M threshold
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def validate_flow_reversal(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Validate if flow reversal is likely.
|
||||||
|
Reversal happens when:
|
||||||
|
- Dealers finish hedging (gamma exposure neutralizes)
|
||||||
|
- Price fails at key levels (VWAP, opening range)
|
||||||
|
- Flow shows distribution pattern (decreasing premium, widening gaps)
|
||||||
|
"""
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
dealer_pressure = current_row.get('dealer_hedge_pressure_score', 0) or 0
|
||||||
|
follow_on_ratio = current_row.get('follow_on_ratio')
|
||||||
|
flow_acceleration = current_row.get('flow_acceleration')
|
||||||
|
|
||||||
|
# Low dealer pressure + low follow-on ratio = reversal likely
|
||||||
|
if dealer_pressure < 30.0:
|
||||||
|
if follow_on_ratio is not None and follow_on_ratio < 0.3:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Negative flow acceleration = flow weakening = reversal
|
||||||
|
if flow_acceleration is not None and flow_acceleration < -10000:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def enrich_with_dealer_context(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Add dealer-aware flow context metrics to DataFrame.
|
||||||
|
Adds: net_gamma_exposure_per_symbol, gamma_flip_proximity, dealer_hedge_pressure_score
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# Initialize columns
|
||||||
|
df['net_gamma_exposure_per_symbol'] = 0.0
|
||||||
|
df['gamma_flip_proximity'] = None
|
||||||
|
df['dealer_hedge_pressure_score'] = 0.0
|
||||||
|
|
||||||
|
# Sort by timestamp for proper gamma tracking
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
|
||||||
|
# Calculate metrics for each row
|
||||||
|
for idx in df_sorted.index:
|
||||||
|
original_idx = df_sorted.index[idx]
|
||||||
|
row = df_sorted.iloc[idx]
|
||||||
|
|
||||||
|
symbol = row.get('symbol_norm')
|
||||||
|
flow_ts_utc = row.get('flow_ts_utc')
|
||||||
|
|
||||||
|
if pd.notna(symbol) and pd.notna(flow_ts_utc):
|
||||||
|
# Net gamma exposure
|
||||||
|
net_gamma = self.calculate_net_gamma_exposure_per_symbol(
|
||||||
|
df_sorted, symbol, flow_ts_utc
|
||||||
|
)
|
||||||
|
df.at[original_idx, 'net_gamma_exposure_per_symbol'] = float(net_gamma)
|
||||||
|
|
||||||
|
# Gamma flip proximity
|
||||||
|
flip_prox = self.calculate_gamma_flip_proximity(df_sorted, idx)
|
||||||
|
if flip_prox is not None:
|
||||||
|
df.at[original_idx, 'gamma_flip_proximity'] = float(flip_prox)
|
||||||
|
|
||||||
|
# Dealer hedge pressure
|
||||||
|
pressure = self.calculate_dealer_hedge_pressure_score(df_sorted, idx)
|
||||||
|
df.at[original_idx, 'dealer_hedge_pressure_score'] = float(pressure)
|
||||||
|
|
||||||
|
logger.info(f"Dealer context enrichment complete. Mean pressure: {df['dealer_hedge_pressure_score'].mean():.2f}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""
|
||||||
|
Flow Decay & Reversal Validation Service
|
||||||
|
Validates flow decay/reversal signals with anchors (premium, dealer pressure, price levels)
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
from utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class FlowState(Enum):
|
||||||
|
"""Flow state classification"""
|
||||||
|
ACTIONABLE = "ACTIONABLE" # Flow decay/reversal is actionable (trade signal)
|
||||||
|
INFORMATIONAL = "INFORMATIONAL" # Flow decay/reversal is informational only (no trade signal)
|
||||||
|
|
||||||
|
|
||||||
|
class FlowDecayValidator:
|
||||||
|
"""
|
||||||
|
Validates flow decay and reversal signals.
|
||||||
|
Flow decay/reversal is actionable ONLY IF:
|
||||||
|
- Premium contracts (high relative premium)
|
||||||
|
- Dealer hedge pressure decreases
|
||||||
|
- Price fails near VWAP / opening range / key level
|
||||||
|
Otherwise mark as INFORMATIONAL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Configuration
|
||||||
|
self.min_relative_premium_for_actionable = 60.0 # Minimum relative premium score
|
||||||
|
self.vwap_failure_threshold_pct = 0.5 # Price within 0.5% of VWAP = failure
|
||||||
|
self.dealer_pressure_decrease_threshold = 20.0 # Dealer pressure decrease threshold
|
||||||
|
|
||||||
|
def validate_flow_decay_reversal(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Validate if flow decay/reversal is actionable.
|
||||||
|
Returns FlowState enum value as string.
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return FlowState.INFORMATIONAL.value
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
|
||||||
|
# Check 1: Premium contracts (relative premium score)
|
||||||
|
relative_premium_score = current_row.get('relative_premium_score', 0) or 0
|
||||||
|
if relative_premium_score < self.min_relative_premium_for_actionable:
|
||||||
|
return FlowState.INFORMATIONAL.value
|
||||||
|
|
||||||
|
# Check 2: Dealer hedge pressure decreases
|
||||||
|
# Look at recent dealer pressure trend
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
|
||||||
|
if pd.notna(symbol) and pd.notna(flow_ts_utc):
|
||||||
|
from datetime import timedelta
|
||||||
|
window_start = flow_ts_utc - timedelta(minutes=30)
|
||||||
|
|
||||||
|
mask = (
|
||||||
|
(df['symbol_norm'] == symbol.upper()) &
|
||||||
|
(df['flow_ts_utc'] >= window_start) &
|
||||||
|
(df['flow_ts_utc'] <= flow_ts_utc) &
|
||||||
|
(df['dealer_hedge_pressure_score'].notna())
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_pressure = df[mask]['dealer_hedge_pressure_score']
|
||||||
|
if len(recent_pressure) >= 2:
|
||||||
|
current_pressure = current_row.get('dealer_hedge_pressure_score', 0) or 0
|
||||||
|
recent_avg = recent_pressure.iloc[:-1].mean()
|
||||||
|
|
||||||
|
pressure_decrease = recent_avg - current_pressure
|
||||||
|
if pressure_decrease < self.dealer_pressure_decrease_threshold:
|
||||||
|
return FlowState.INFORMATIONAL.value
|
||||||
|
|
||||||
|
# Check 3: Price fails near VWAP / opening range / key level
|
||||||
|
price_vs_vwap_pct = current_row.get('price_vs_vwap_pct')
|
||||||
|
pct_vs_rth_open = current_row.get('pct_vs_rth_open')
|
||||||
|
|
||||||
|
price_failure = False
|
||||||
|
|
||||||
|
# VWAP failure
|
||||||
|
if price_vs_vwap_pct is not None and not pd.isna(price_vs_vwap_pct):
|
||||||
|
if abs(price_vs_vwap_pct) <= self.vwap_failure_threshold_pct:
|
||||||
|
price_failure = True
|
||||||
|
|
||||||
|
# Opening range failure
|
||||||
|
if pct_vs_rth_open is not None and not pd.isna(pct_vs_rth_open):
|
||||||
|
if abs(pct_vs_rth_open) <= 0.3: # Within 0.3% of open
|
||||||
|
price_failure = True
|
||||||
|
|
||||||
|
if not price_failure:
|
||||||
|
return FlowState.INFORMATIONAL.value
|
||||||
|
|
||||||
|
# All checks passed: actionable
|
||||||
|
return FlowState.ACTIONABLE.value
|
||||||
|
|
||||||
|
def enrich_with_flow_state(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Add flow state validation to DataFrame.
|
||||||
|
Adds: flow_state
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df['flow_state'] = FlowState.ACTIONABLE.value # Default to actionable
|
||||||
|
|
||||||
|
# Only validate flow decay/reversal cases
|
||||||
|
# For now, mark all as actionable (can be enhanced based on flow_acceleration, follow_on_ratio)
|
||||||
|
# This is a placeholder - in production, you'd identify decay/reversal patterns first
|
||||||
|
|
||||||
|
logger.info(f"Flow state validation complete. Actionable: {(df['flow_state'] == FlowState.ACTIONABLE.value).sum()}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
"""
|
||||||
|
Institutional Confidence Metrics Service
|
||||||
|
Calculates confidence scores for institutional flow signals
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
from utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class InstitutionalConfidence:
|
||||||
|
"""
|
||||||
|
Calculates institutional confidence metrics:
|
||||||
|
- confidence_score (0-100)
|
||||||
|
- institutional_likelihood (0-1)
|
||||||
|
- dealer_pain_level (0-100)
|
||||||
|
- expected_move_vs_implied (ratio)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Configuration
|
||||||
|
self.min_premium_for_institutional = 200000 # Minimum premium for institutional classification
|
||||||
|
|
||||||
|
def calculate_confidence_score(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculate overall confidence score (0-100) combining multiple factors.
|
||||||
|
Higher = more confidence in signal quality.
|
||||||
|
"""
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Relative premium component (0-25 points)
|
||||||
|
relative_premium = row.get('relative_premium_score', 0) or 0
|
||||||
|
score += (relative_premium / 100.0) * 25.0
|
||||||
|
|
||||||
|
# Signal strength component (0-25 points)
|
||||||
|
signal_strength = row.get('signal_strength', 0) or 0
|
||||||
|
score += (signal_strength / 100.0) * 25.0
|
||||||
|
|
||||||
|
# Dealer pressure component (0-20 points)
|
||||||
|
dealer_pressure = row.get('dealer_hedge_pressure_score', 0) or 0
|
||||||
|
score += (dealer_pressure / 100.0) * 20.0
|
||||||
|
|
||||||
|
# Flow continuation component (0-15 points)
|
||||||
|
follow_on_ratio = row.get('follow_on_ratio')
|
||||||
|
if follow_on_ratio is not None and not pd.isna(follow_on_ratio):
|
||||||
|
score += follow_on_ratio * 15.0
|
||||||
|
|
||||||
|
# Strike laddering component (0-15 points)
|
||||||
|
strike_laddering = row.get('strike_laddering_detected', False)
|
||||||
|
if strike_laddering:
|
||||||
|
score += 15.0
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, score))
|
||||||
|
|
||||||
|
def calculate_institutional_likelihood(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculate likelihood that flow is institutional (0-1).
|
||||||
|
Based on premium size, trade characteristics, and patterns.
|
||||||
|
"""
|
||||||
|
premium_num = row.get('premium_num', 0) or 0
|
||||||
|
relative_premium = row.get('relative_premium_score', 0) or 0
|
||||||
|
size_concentration = row.get('size_concentration_score', 0) or 0
|
||||||
|
|
||||||
|
likelihood = 0.0
|
||||||
|
|
||||||
|
# Premium size component (0-40%)
|
||||||
|
if premium_num >= 1000000: # $1M+
|
||||||
|
likelihood += 0.40
|
||||||
|
elif premium_num >= 500000: # $500K+
|
||||||
|
likelihood += 0.30
|
||||||
|
elif premium_num >= self.min_premium_for_institutional:
|
||||||
|
likelihood += 0.20
|
||||||
|
|
||||||
|
# Relative premium component (0-30%)
|
||||||
|
if relative_premium >= 80:
|
||||||
|
likelihood += 0.30
|
||||||
|
elif relative_premium >= 60:
|
||||||
|
likelihood += 0.20
|
||||||
|
elif relative_premium >= 40:
|
||||||
|
likelihood += 0.10
|
||||||
|
|
||||||
|
# Size concentration component (0-30%)
|
||||||
|
# Institutional trades are often concentrated
|
||||||
|
if size_concentration >= 70:
|
||||||
|
likelihood += 0.30
|
||||||
|
elif size_concentration >= 50:
|
||||||
|
likelihood += 0.20
|
||||||
|
elif size_concentration >= 30:
|
||||||
|
likelihood += 0.10
|
||||||
|
|
||||||
|
return min(1.0, max(0.0, likelihood))
|
||||||
|
|
||||||
|
def calculate_dealer_pain_level(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculate dealer pain level (0-100).
|
||||||
|
Higher = dealers in pain (large gamma exposure, forced to hedge).
|
||||||
|
"""
|
||||||
|
dealer_pressure = row.get('dealer_hedge_pressure_score', 0) or 0
|
||||||
|
net_gamma = abs(row.get('net_gamma_exposure_per_symbol', 0) or 0)
|
||||||
|
gamma_flip_prox = row.get('gamma_flip_proximity')
|
||||||
|
|
||||||
|
pain = 0.0
|
||||||
|
|
||||||
|
# Dealer pressure component (0-50 points)
|
||||||
|
pain += (dealer_pressure / 100.0) * 50.0
|
||||||
|
|
||||||
|
# Gamma exposure component (0-30 points)
|
||||||
|
# Large absolute gamma = more pain
|
||||||
|
if net_gamma > 0:
|
||||||
|
normalized_gamma = min(1.0, net_gamma / 1000000000) # 1B threshold
|
||||||
|
pain += normalized_gamma * 30.0
|
||||||
|
|
||||||
|
# Gamma flip proximity component (0-20 points)
|
||||||
|
# Near flip = high pain (dealers forced to adjust)
|
||||||
|
if gamma_flip_prox is not None and not pd.isna(gamma_flip_prox):
|
||||||
|
# Absolute value of proximity (closer to 0 = more pain)
|
||||||
|
pain += (1.0 - abs(gamma_flip_prox)) * 20.0
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, pain))
|
||||||
|
|
||||||
|
def calculate_expected_move_vs_implied(self, row: pd.Series) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate expected move vs implied move ratio.
|
||||||
|
Estimates expected move from flow characteristics vs implied volatility.
|
||||||
|
|
||||||
|
Returns: ratio (expected_move / implied_move)
|
||||||
|
- >1.0 = flow suggests larger move than implied
|
||||||
|
- <1.0 = flow suggests smaller move than implied
|
||||||
|
- None if cannot calculate
|
||||||
|
"""
|
||||||
|
# Simplified calculation: use premium and delta exposure as proxies
|
||||||
|
premium_num = row.get('premium_num', 0) or 0
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
delta_exposure = abs(row.get('delta_exposure', 0) or 0)
|
||||||
|
|
||||||
|
if pd.isna(spot_num) or spot_num == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Estimate expected move from premium paid
|
||||||
|
# High premium relative to spot = expectation of larger move
|
||||||
|
if premium_num > 0:
|
||||||
|
prem_to_spot_ratio = premium_num / spot_num
|
||||||
|
|
||||||
|
# Estimate implied move (simplified: assume 1% IV = 1% move expectation)
|
||||||
|
# This is a placeholder - in production, use actual IV from options chain
|
||||||
|
implied_move_pct = 2.0 # Default 2% implied move
|
||||||
|
|
||||||
|
# Estimate expected move from premium
|
||||||
|
# Premium of 2% of spot = expectation of ~2% move (rough approximation)
|
||||||
|
expected_move_pct = prem_to_spot_ratio * 100.0
|
||||||
|
|
||||||
|
# Calculate ratio
|
||||||
|
if implied_move_pct > 0:
|
||||||
|
ratio = expected_move_pct / implied_move_pct
|
||||||
|
return float(ratio)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def enrich_with_confidence_metrics(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Add institutional confidence metrics to DataFrame.
|
||||||
|
Adds: confidence_score, institutional_likelihood, dealer_pain_level, expected_move_vs_implied
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# Initialize columns
|
||||||
|
df['confidence_score'] = 0.0
|
||||||
|
df['institutional_likelihood'] = 0.0
|
||||||
|
df['dealer_pain_level'] = 0.0
|
||||||
|
df['expected_move_vs_implied'] = None
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
df['confidence_score'] = df.apply(self.calculate_confidence_score, axis=1)
|
||||||
|
df['institutional_likelihood'] = df.apply(self.calculate_institutional_likelihood, axis=1)
|
||||||
|
df['dealer_pain_level'] = df.apply(self.calculate_dealer_pain_level, axis=1)
|
||||||
|
|
||||||
|
# Expected move vs implied (some rows may not have this)
|
||||||
|
for idx in df.index:
|
||||||
|
expected_move = self.calculate_expected_move_vs_implied(df.iloc[idx])
|
||||||
|
if expected_move is not None:
|
||||||
|
df.at[idx, 'expected_move_vs_implied'] = float(expected_move)
|
||||||
|
|
||||||
|
logger.info(f"Confidence metrics complete. Mean confidence: {df['confidence_score'].mean():.2f}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
"""
|
||||||
|
Intent Classification Service
|
||||||
|
Replaces naive direction (BULL/BEAR) with nuanced volatility and hedging intent classification
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
from utils.logger import logger
|
||||||
|
from utils.error_handler import safe_divide
|
||||||
|
|
||||||
|
|
||||||
|
class VolatilityIntent(Enum):
|
||||||
|
"""Volatility and hedging intent classification"""
|
||||||
|
LONG_VOL = "LONG_VOL" # Buying volatility (call/put buying, expecting large moves)
|
||||||
|
SHORT_VOL = "SHORT_VOL" # Selling volatility (premium collection, expecting low vol)
|
||||||
|
DIRECTIONAL = "DIRECTIONAL" # Directional positioning (directional bias)
|
||||||
|
HEDGE_UNWIND = "HEDGE_UNWIND" # Hedging or unwinding existing positions
|
||||||
|
|
||||||
|
|
||||||
|
class IntentClassifier:
|
||||||
|
"""
|
||||||
|
Classifies options flow intent beyond simple BULL/BEAR direction.
|
||||||
|
Identifies volatility trading, hedging, and directional positioning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Configuration thresholds
|
||||||
|
self.otm_threshold_pct = 5.0 # Strikes >5% OTM considered OTM
|
||||||
|
self.long_vol_premium_threshold = 100000 # Minimum premium for long vol signal
|
||||||
|
self.short_vol_premium_threshold = 200000 # Minimum premium for short vol signal
|
||||||
|
|
||||||
|
def estimate_delta(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Estimate option delta from moneyness.
|
||||||
|
Returns delta estimate (0-1 for calls, -1-0 for puts, use absolute value).
|
||||||
|
"""
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
strike_num = row.get('strike_num', 0) or 0
|
||||||
|
cp_norm = row.get('cp_norm', '')
|
||||||
|
|
||||||
|
if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num) or pd.isna(cp_norm):
|
||||||
|
return 0.5 # Default to ATM
|
||||||
|
|
||||||
|
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
|
||||||
|
|
||||||
|
if cp_norm == 'CALL':
|
||||||
|
if strike_num <= spot_num:
|
||||||
|
# ITM call: delta 0.5 to 1.0
|
||||||
|
delta = 0.5 + (1.0 - moneyness_ratio) * 0.5
|
||||||
|
else:
|
||||||
|
# OTM call: delta 0.5 to 0.0
|
||||||
|
delta = max(0.0, 0.5 * (1.5 - moneyness_ratio) / 0.5)
|
||||||
|
else: # PUT
|
||||||
|
if strike_num >= spot_num:
|
||||||
|
# ITM put: delta -0.5 to -1.0 (return absolute)
|
||||||
|
delta = abs(0.5 + (moneyness_ratio - 1.0) * 0.5)
|
||||||
|
else:
|
||||||
|
# OTM put: delta -0.5 to 0.0 (return absolute)
|
||||||
|
delta = max(0.0, 0.5 * (1.0 - moneyness_ratio) / 0.5)
|
||||||
|
|
||||||
|
return float(delta)
|
||||||
|
|
||||||
|
def estimate_gamma(self, row: pd.Series, delta: float) -> float:
|
||||||
|
"""
|
||||||
|
Estimate option gamma (sensitivity of delta to price changes).
|
||||||
|
Higher near ATM, lower ITM/OTM.
|
||||||
|
Returns gamma estimate (positive value).
|
||||||
|
"""
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
strike_num = row.get('strike_num', 0) or 0
|
||||||
|
|
||||||
|
if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Gamma is highest at-the-money
|
||||||
|
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
|
||||||
|
|
||||||
|
# Simple approximation: gamma peaks at 1.0 (ATM) and decays away
|
||||||
|
# Use normal distribution approximation
|
||||||
|
distance_from_atm = abs(moneyness_ratio - 1.0)
|
||||||
|
|
||||||
|
# Gamma ≈ exp(-distance^2 / (2*sigma^2)) where sigma ≈ 0.1 (10% moneyness)
|
||||||
|
gamma = np.exp(-(distance_from_atm ** 2) / (2 * 0.01))
|
||||||
|
|
||||||
|
return float(gamma)
|
||||||
|
|
||||||
|
def calculate_delta_exposure(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculate delta exposure: contracts * delta * 100 * spot_price.
|
||||||
|
Positive = long delta (bullish), Negative = short delta (bearish).
|
||||||
|
"""
|
||||||
|
premium_num = row.get('premium_num', 0) or 0
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
vol_num = row.get('vol_num', 0) or 0
|
||||||
|
cp_norm = row.get('cp_norm', '')
|
||||||
|
side_norm = row.get('side_norm', '')
|
||||||
|
|
||||||
|
if pd.isna(spot_num) or spot_num == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Estimate delta
|
||||||
|
delta = self.estimate_delta(row)
|
||||||
|
|
||||||
|
# Determine sign based on call/put and buy/sell
|
||||||
|
if cp_norm == 'CALL':
|
||||||
|
if side_norm == 'BUY':
|
||||||
|
delta_sign = 1.0 # Long calls = positive delta
|
||||||
|
else: # SELL
|
||||||
|
delta_sign = -1.0 # Short calls = negative delta
|
||||||
|
else: # PUT
|
||||||
|
if side_norm == 'BUY':
|
||||||
|
delta_sign = -1.0 # Long puts = negative delta
|
||||||
|
else: # SELL
|
||||||
|
delta_sign = 1.0 # Short puts = positive delta
|
||||||
|
|
||||||
|
# Delta exposure = contracts * delta * 100 * spot
|
||||||
|
# Use volume as proxy for contracts
|
||||||
|
contracts = vol_num if not pd.isna(vol_num) else 0
|
||||||
|
delta_exposure = contracts * delta * delta_sign * 100 * spot_num
|
||||||
|
|
||||||
|
return float(delta_exposure)
|
||||||
|
|
||||||
|
def calculate_gamma_exposure(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculate gamma exposure: contracts * gamma * 100 * spot_price^2.
|
||||||
|
Positive = long gamma (volatility long), Negative = short gamma (volatility short).
|
||||||
|
"""
|
||||||
|
premium_num = row.get('premium_num', 0) or 0
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
vol_num = row.get('vol_num', 0) or 0
|
||||||
|
cp_norm = row.get('cp_norm', '')
|
||||||
|
side_norm = row.get('side_norm', '')
|
||||||
|
|
||||||
|
if pd.isna(spot_num) or spot_num == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Estimate delta and gamma
|
||||||
|
delta = self.estimate_delta(row)
|
||||||
|
gamma = self.estimate_gamma(row, delta)
|
||||||
|
|
||||||
|
# Determine sign: buying options = long gamma, selling = short gamma
|
||||||
|
if side_norm == 'BUY':
|
||||||
|
gamma_sign = 1.0 # Long gamma
|
||||||
|
else: # SELL
|
||||||
|
gamma_sign = -1.0 # Short gamma
|
||||||
|
|
||||||
|
# Gamma exposure = contracts * gamma * 100 * spot^2
|
||||||
|
contracts = vol_num if not pd.isna(vol_num) else 0
|
||||||
|
gamma_exposure = contracts * gamma * gamma_sign * 100 * (spot_num ** 2)
|
||||||
|
|
||||||
|
return float(gamma_exposure)
|
||||||
|
|
||||||
|
def classify_volatility_intent(self, row: pd.Series) -> str:
|
||||||
|
"""
|
||||||
|
Classify volatility intent based on trade characteristics.
|
||||||
|
Returns VolatilityIntent enum value as string.
|
||||||
|
"""
|
||||||
|
premium_num = row.get('premium_num', 0) or 0
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
strike_num = row.get('strike_num', 0) or 0
|
||||||
|
cp_norm = row.get('cp_norm', '')
|
||||||
|
side_norm = row.get('side_norm', '')
|
||||||
|
vol_num = row.get('vol_num', 0) or 0
|
||||||
|
oi_num = row.get('oi_num', 0) or 0
|
||||||
|
|
||||||
|
# Calculate moneyness
|
||||||
|
if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num):
|
||||||
|
mny_pct = 0.0
|
||||||
|
else:
|
||||||
|
if cp_norm == 'CALL':
|
||||||
|
mny_pct = ((strike_num - spot_num) / spot_num * 100.0) if spot_num > 0 else 0
|
||||||
|
else: # PUT
|
||||||
|
mny_pct = ((spot_num - strike_num) / spot_num * 100.0) if spot_num > 0 else 0
|
||||||
|
|
||||||
|
is_otm = abs(mny_pct) > self.otm_threshold_pct
|
||||||
|
|
||||||
|
# Long volatility: buying OTM options (calls or puts)
|
||||||
|
# High premium, OTM strikes, buying side
|
||||||
|
if (side_norm == 'BUY' and
|
||||||
|
is_otm and
|
||||||
|
premium_num >= self.long_vol_premium_threshold):
|
||||||
|
return VolatilityIntent.LONG_VOL.value
|
||||||
|
|
||||||
|
# Short volatility: selling options, collecting premium
|
||||||
|
# High premium, selling side, vol > OI (opening new short positions)
|
||||||
|
if (side_norm == 'SELL' and
|
||||||
|
premium_num >= self.short_vol_premium_threshold and
|
||||||
|
vol_num > oi_num):
|
||||||
|
return VolatilityIntent.SHORT_VOL.value
|
||||||
|
|
||||||
|
# Directional: ITM options, buying side, strong directional flow
|
||||||
|
if (side_norm == 'BUY' and
|
||||||
|
not is_otm and
|
||||||
|
premium_num >= 50000):
|
||||||
|
return VolatilityIntent.DIRECTIONAL.value
|
||||||
|
|
||||||
|
# Hedge/Unwind: Selling existing positions (vol < OI)
|
||||||
|
# Or buying protective puts/calls
|
||||||
|
if (side_norm == 'SELL' and vol_num < oi_num) or \
|
||||||
|
(side_norm == 'BUY' and is_otm and premium_num < self.long_vol_premium_threshold):
|
||||||
|
return VolatilityIntent.HEDGE_UNWIND.value
|
||||||
|
|
||||||
|
# Default: directional (fallback)
|
||||||
|
return VolatilityIntent.DIRECTIONAL.value
|
||||||
|
|
||||||
|
def enrich_with_intent_classification(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Add intent classification metrics to DataFrame.
|
||||||
|
Adds: delta_exposure, gamma_exposure, volatility_intent
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# Initialize columns
|
||||||
|
df['delta_exposure'] = 0.0
|
||||||
|
df['gamma_exposure'] = 0.0
|
||||||
|
df['volatility_intent'] = VolatilityIntent.DIRECTIONAL.value
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
df['delta_exposure'] = df.apply(self.calculate_delta_exposure, axis=1)
|
||||||
|
df['gamma_exposure'] = df.apply(self.calculate_gamma_exposure, axis=1)
|
||||||
|
df['volatility_intent'] = df.apply(self.classify_volatility_intent, axis=1)
|
||||||
|
|
||||||
|
# Log distribution
|
||||||
|
intent_counts = df['volatility_intent'].value_counts()
|
||||||
|
logger.info(f"Intent classification complete. Distribution: {intent_counts.to_dict()}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""
|
||||||
|
Market Regime Detection Service
|
||||||
|
Identifies market regime to gate trade signal generation
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
from utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class MarketRegime(Enum):
|
||||||
|
"""Market regime classification"""
|
||||||
|
TREND = "TREND" # Trending market (continuation bias)
|
||||||
|
RANGE = "RANGE" # Range-bound market (fade or vol-sell bias)
|
||||||
|
HIGH_VOL_EVENT = "HIGH_VOL_EVENT" # High volatility event (volatility expansion bias)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketRegimeDetector:
|
||||||
|
"""
|
||||||
|
Detects market regime to inform trade signal bias:
|
||||||
|
- Trend: continuation trades preferred
|
||||||
|
- Range: mean reversion / vol selling preferred
|
||||||
|
- High Vol Event: volatility expansion trades preferred
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Configuration
|
||||||
|
self.trend_threshold_pct = 1.5 # >1.5% move = trending
|
||||||
|
self.range_threshold_pct = 0.5 # <0.5% move = ranging
|
||||||
|
self.high_vol_threshold_pct = 3.0 # >3% move = high vol event
|
||||||
|
self.lookback_minutes = 60 # Lookback window for regime detection
|
||||||
|
|
||||||
|
def detect_regime(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Detect market regime for a given flow event.
|
||||||
|
Returns MarketRegime enum value as string.
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return MarketRegime.RANGE.value
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(flow_ts_utc):
|
||||||
|
return MarketRegime.RANGE.value
|
||||||
|
|
||||||
|
# Get price movement over lookback window
|
||||||
|
pct_vs_rth_open = current_row.get('pct_vs_rth_open')
|
||||||
|
pct_vs_prior_close = current_row.get('pct_vs_prior_close')
|
||||||
|
pct_15m_momo = current_row.get('pct_15m_momo')
|
||||||
|
|
||||||
|
# Use most appropriate price metric
|
||||||
|
price_move_pct = None
|
||||||
|
if pct_vs_rth_open is not None and not pd.isna(pct_vs_rth_open):
|
||||||
|
price_move_pct = abs(pct_vs_rth_open)
|
||||||
|
elif pct_vs_prior_close is not None and not pd.isna(pct_vs_prior_close):
|
||||||
|
price_move_pct = abs(pct_vs_prior_close)
|
||||||
|
elif pct_15m_momo is not None and not pd.isna(pct_15m_momo):
|
||||||
|
price_move_pct = abs(pct_15m_momo)
|
||||||
|
|
||||||
|
if price_move_pct is None:
|
||||||
|
return MarketRegime.RANGE.value # Default to range
|
||||||
|
|
||||||
|
# Classify regime
|
||||||
|
if price_move_pct >= self.high_vol_threshold_pct:
|
||||||
|
return MarketRegime.HIGH_VOL_EVENT.value
|
||||||
|
elif price_move_pct >= self.trend_threshold_pct:
|
||||||
|
return MarketRegime.TREND.value
|
||||||
|
elif price_move_pct <= self.range_threshold_pct:
|
||||||
|
return MarketRegime.RANGE.value
|
||||||
|
else:
|
||||||
|
# Between range and trend threshold - classify based on momentum
|
||||||
|
if pct_15m_momo is not None and not pd.isna(pct_15m_momo):
|
||||||
|
if abs(pct_15m_momo) > 0.75:
|
||||||
|
return MarketRegime.TREND.value
|
||||||
|
return MarketRegime.RANGE.value
|
||||||
|
|
||||||
|
def enrich_with_market_regime(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Add market regime classification to DataFrame.
|
||||||
|
Adds: market_regime
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df['market_regime'] = MarketRegime.RANGE.value
|
||||||
|
|
||||||
|
# Detect regime for each row
|
||||||
|
for idx in df.index:
|
||||||
|
regime = self.detect_regime(df, idx)
|
||||||
|
df.at[idx, 'market_regime'] = regime
|
||||||
|
|
||||||
|
regime_counts = df['market_regime'].value_counts()
|
||||||
|
logger.info(f"Market regime detection complete. Distribution: {regime_counts.to_dict()}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
"""
|
||||||
|
Tier-0 Noise Rejection Service
|
||||||
|
Filters out low-quality signals before enrichment to reduce processing overhead
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import timedelta
|
||||||
|
from utils.logger import logger
|
||||||
|
from utils.error_handler import safe_divide
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseRejector:
|
||||||
|
"""
|
||||||
|
Rejects early-stage noise before enrichment to optimize processing.
|
||||||
|
|
||||||
|
Rejects if:
|
||||||
|
- Single isolated trade (no repeat activity)
|
||||||
|
- Far OTM weekly lottos
|
||||||
|
- Delta-adjusted premium below threshold
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Configuration
|
||||||
|
self.repeat_activity_window_minutes = 30 # Minutes to look for repeat activity
|
||||||
|
self.min_delta_adjusted_premium = 50000 # Minimum delta-adjusted premium
|
||||||
|
self.max_otm_percentage = 15.0 # Reject strikes >15% OTM
|
||||||
|
self.min_expiry_days = 1 # Minimum days to expiry (reject same-day/weekly lottos)
|
||||||
|
|
||||||
|
def is_isolated_trade(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if trade is isolated (no repeat activity within window).
|
||||||
|
Returns True if isolated (should reject).
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return True
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
direction = current_row.get('direction')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(flow_ts_utc):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Look for trades in same direction within window
|
||||||
|
window_start = flow_ts_utc - timedelta(minutes=self.repeat_activity_window_minutes)
|
||||||
|
|
||||||
|
same_symbol = df['symbol_norm'] == symbol
|
||||||
|
same_direction = df['direction'] == direction
|
||||||
|
in_window = (df['flow_ts_utc'] >= window_start) & (df['flow_ts_utc'] < flow_ts_utc)
|
||||||
|
|
||||||
|
# Exclude current row
|
||||||
|
other_trades = df[same_symbol & same_direction & in_window]
|
||||||
|
|
||||||
|
# If no other trades in window, it's isolated
|
||||||
|
return len(other_trades) == 0
|
||||||
|
|
||||||
|
def is_far_otm_lotto(
|
||||||
|
self,
|
||||||
|
row: pd.Series
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if trade is a far OTM weekly lotto (low probability, low signal value).
|
||||||
|
Returns True if should reject.
|
||||||
|
"""
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
strike_num = row.get('strike_num', 0) or 0
|
||||||
|
cp_norm = row.get('cp_norm', '')
|
||||||
|
exp_date = row.get('exp_date')
|
||||||
|
flow_date = row.get('flow_date_cst')
|
||||||
|
|
||||||
|
if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num) or pd.isna(cp_norm):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Calculate moneyness percentage
|
||||||
|
if cp_norm == 'CALL':
|
||||||
|
otm_pct = ((strike_num - spot_num) / spot_num * 100.0) if spot_num > 0 else 0
|
||||||
|
else: # PUT
|
||||||
|
otm_pct = ((spot_num - strike_num) / spot_num * 100.0) if spot_num > 0 else 0
|
||||||
|
|
||||||
|
# Reject if >15% OTM
|
||||||
|
if otm_pct > self.max_otm_percentage:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if weekly lotto (expiry within 7 days)
|
||||||
|
if pd.notna(exp_date) and pd.notna(flow_date):
|
||||||
|
try:
|
||||||
|
if isinstance(exp_date, str):
|
||||||
|
from datetime import datetime
|
||||||
|
exp_date = datetime.strptime(exp_date, '%Y-%m-%d').date()
|
||||||
|
if isinstance(flow_date, str):
|
||||||
|
from datetime import datetime
|
||||||
|
flow_date = datetime.strptime(flow_date, '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
days_to_expiry = (exp_date - flow_date).days
|
||||||
|
|
||||||
|
# Reject weekly lottos that are also far OTM
|
||||||
|
if days_to_expiry <= 7 and otm_pct > 10.0:
|
||||||
|
return True
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def calculate_delta_adjusted_premium(
|
||||||
|
self,
|
||||||
|
row: pd.Series
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate delta-adjusted premium (premium * |delta|).
|
||||||
|
Approximates intrinsic value component of premium.
|
||||||
|
|
||||||
|
For simplicity, we estimate delta from moneyness:
|
||||||
|
- ATM: delta ≈ 0.5
|
||||||
|
- ITM: delta increases toward 1.0
|
||||||
|
- OTM: delta decreases toward 0.0
|
||||||
|
"""
|
||||||
|
premium_num = row.get('premium_num', 0) or 0
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
strike_num = row.get('strike_num', 0) or 0
|
||||||
|
cp_norm = row.get('cp_norm', '')
|
||||||
|
|
||||||
|
if pd.isna(premium_num) or premium_num == 0 or pd.isna(spot_num) or spot_num == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Estimate delta from moneyness
|
||||||
|
if cp_norm == 'CALL':
|
||||||
|
if strike_num <= spot_num:
|
||||||
|
# ITM call: delta from 0.5 to 1.0
|
||||||
|
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
|
||||||
|
# Strike at spot = 0.5, strike at 0 = 1.0
|
||||||
|
estimated_delta = 0.5 + (1.0 - moneyness_ratio) * 0.5
|
||||||
|
else:
|
||||||
|
# OTM call: delta from 0.5 to 0.0
|
||||||
|
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
|
||||||
|
# Strike at spot = 0.5, strike at 1.15x spot = 0.0
|
||||||
|
estimated_delta = max(0.0, 0.5 * (1.5 - moneyness_ratio) / 0.5)
|
||||||
|
else: # PUT
|
||||||
|
if strike_num >= spot_num:
|
||||||
|
# ITM put: delta from -0.5 to -1.0 (use absolute value)
|
||||||
|
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
|
||||||
|
estimated_delta = 0.5 + (moneyness_ratio - 1.0) * 0.5
|
||||||
|
else:
|
||||||
|
# OTM put: delta from 0.5 to 0.0 (use absolute value)
|
||||||
|
moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0
|
||||||
|
estimated_delta = max(0.0, 0.5 * (1.0 - moneyness_ratio) / 0.5)
|
||||||
|
|
||||||
|
# Delta-adjusted premium
|
||||||
|
delta_adj_premium = premium_num * abs(estimated_delta)
|
||||||
|
|
||||||
|
return float(delta_adj_premium)
|
||||||
|
|
||||||
|
def should_reject(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Determine if row should be rejected as noise.
|
||||||
|
Returns True if should reject (mark early_noise_reject = True).
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return True
|
||||||
|
|
||||||
|
row = df.iloc[row_idx]
|
||||||
|
|
||||||
|
# Reject isolated trades
|
||||||
|
if self.is_isolated_trade(df, row_idx):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Reject far OTM lottos
|
||||||
|
if self.is_far_otm_lotto(row):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Reject if delta-adjusted premium below threshold
|
||||||
|
delta_adj_premium = self.calculate_delta_adjusted_premium(row)
|
||||||
|
if delta_adj_premium < self.min_delta_adjusted_premium:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def mark_noise_rejections(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Mark rows for early noise rejection.
|
||||||
|
Adds: early_noise_reject (boolean)
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# Initialize column
|
||||||
|
df['early_noise_reject'] = False
|
||||||
|
|
||||||
|
# Sort by timestamp for proper isolation detection
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
|
||||||
|
# Mark rejections
|
||||||
|
for idx in df_sorted.index:
|
||||||
|
if self.should_reject(df_sorted, idx):
|
||||||
|
original_idx = df_sorted.index[idx]
|
||||||
|
df.at[original_idx, 'early_noise_reject'] = True
|
||||||
|
|
||||||
|
rejection_count = df['early_noise_reject'].sum()
|
||||||
|
logger.info(f"Noise rejection: {rejection_count}/{len(df)} rows marked for rejection ({rejection_count/len(df)*100:.1f}%)")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -146,5 +146,14 @@ class OutputFormatter:
|
||||||
# Ensure Phase 1 columns are preserved (don't drop them)
|
# Ensure Phase 1 columns are preserved (don't drop them)
|
||||||
# Phase 1 columns should remain as-is (signal_tier, checklist_score, etc.)
|
# Phase 1 columns should remain as-is (signal_tier, checklist_score, etc.)
|
||||||
|
|
||||||
|
# Ensure all new institutional analytics columns are preserved:
|
||||||
|
# - premium_zscore, premium_percentile_intraday, relative_premium_score
|
||||||
|
# - aggression_score, size_concentration_score, repeat_trade_velocity, strike_clustering_score, signal_strength
|
||||||
|
# - early_noise_reject, flow_acceleration, time_between_hits, follow_on_ratio, strike_laddering_detected
|
||||||
|
# - delta_exposure, gamma_exposure, volatility_intent
|
||||||
|
# - net_gamma_exposure_per_symbol, gamma_flip_proximity, dealer_hedge_pressure_score
|
||||||
|
# - market_regime, flow_state
|
||||||
|
# - confidence_score, institutional_likelihood, dealer_pain_level, expected_move_vs_implied
|
||||||
|
|
||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
"""
|
||||||
|
Relative Premium Scoring Service
|
||||||
|
Replaces static premium filter with context-aware relative premium scoring
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import asyncpg
|
||||||
|
from utils.logger import logger
|
||||||
|
from utils.error_handler import safe_divide
|
||||||
|
|
||||||
|
|
||||||
|
class RelativePremiumScorer:
|
||||||
|
"""
|
||||||
|
Computes relative premium metrics to replace static premium filtering.
|
||||||
|
Premium of $80K might be significant for AAPL but noise for TSLA.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pool: asyncpg.Pool = None):
|
||||||
|
self.pool = pool
|
||||||
|
# Configuration
|
||||||
|
self.rolling_window_days = 20 # Rolling window for z-score calculation
|
||||||
|
self.min_relative_premium_threshold = 0.60 # 60th percentile minimum
|
||||||
|
|
||||||
|
async def fetch_historical_premium_stats(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
reference_date: datetime.date
|
||||||
|
) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
Fetch historical premium statistics for a symbol.
|
||||||
|
Returns: {mean, std, min, max, median} for rolling window
|
||||||
|
"""
|
||||||
|
if not self.pool:
|
||||||
|
logger.warning("No database pool available for historical premium stats")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate window start date
|
||||||
|
window_start = reference_date - timedelta(days=self.rolling_window_days + 5) # Extra buffer
|
||||||
|
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
AVG(premium_num) as mean_premium,
|
||||||
|
STDDEV(premium_num) as std_premium,
|
||||||
|
MIN(premium_num) as min_premium,
|
||||||
|
MAX(premium_num) as max_premium,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY premium_num) as median_premium
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
CAST(REGEXP_REPLACE("Premium"::text, '[^\d.]', '', 'g') AS numeric) as premium_num
|
||||||
|
FROM "OptionsFlow_monthly"
|
||||||
|
WHERE UPPER(TRIM("Symbol")) = $1
|
||||||
|
AND "Premium" IS NOT NULL
|
||||||
|
AND TRIM("Premium"::text) <> ''
|
||||||
|
AND "CreatedDate" >= $2
|
||||||
|
AND "CreatedDate" <= $3
|
||||||
|
AND "StockEtf" = 'STOCK'
|
||||||
|
) subq
|
||||||
|
WHERE premium_num > 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
query,
|
||||||
|
symbol.upper(),
|
||||||
|
window_start.strftime('%Y-%m-%d'),
|
||||||
|
reference_date.strftime('%Y-%m-%d')
|
||||||
|
)
|
||||||
|
|
||||||
|
if row and row['mean_premium']:
|
||||||
|
return {
|
||||||
|
'mean': float(row['mean_premium']) if row['mean_premium'] else 0.0,
|
||||||
|
'std': float(row['std_premium']) if row['std_premium'] else 0.0,
|
||||||
|
'min': float(row['min_premium']) if row['min_premium'] else 0.0,
|
||||||
|
'max': float(row['max_premium']) if row['max_premium'] else 0.0,
|
||||||
|
'median': float(row['median_premium']) if row['median_premium'] else 0.0,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error fetching historical premium stats for {symbol}: {e}")
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def calculate_intraday_percentile(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
symbol: str,
|
||||||
|
flow_date: datetime.date,
|
||||||
|
premium: float
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate percentile rank of premium within same-day flow for the symbol.
|
||||||
|
Returns 0-100 percentile value.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Filter to same symbol and date
|
||||||
|
same_day_flow = df[
|
||||||
|
(df['symbol_norm'] == symbol.upper()) &
|
||||||
|
(df['flow_date_cst'] == flow_date) &
|
||||||
|
(df['premium_num'].notna()) &
|
||||||
|
(df['premium_num'] > 0)
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(same_day_flow) < 3: # Need at least 3 trades for meaningful percentile
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate percentile rank
|
||||||
|
all_premiums = same_day_flow['premium_num'].values
|
||||||
|
percentile = (np.sum(all_premiums <= premium) / len(all_premiums)) * 100.0
|
||||||
|
|
||||||
|
return float(percentile)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error calculating intraday percentile for {symbol}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calculate_zscore(
|
||||||
|
self,
|
||||||
|
premium: float,
|
||||||
|
stats: Dict[str, float]
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate z-score of premium relative to historical distribution.
|
||||||
|
Returns z-score (standard deviations from mean).
|
||||||
|
"""
|
||||||
|
if not stats or stats.get('std', 0) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mean = stats.get('mean', 0)
|
||||||
|
std = stats.get('std', 1)
|
||||||
|
|
||||||
|
if std == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
zscore = (premium - mean) / std
|
||||||
|
return float(zscore)
|
||||||
|
|
||||||
|
def calculate_relative_premium_score(
|
||||||
|
self,
|
||||||
|
premium: float,
|
||||||
|
zscore: Optional[float],
|
||||||
|
intraday_percentile: Optional[float],
|
||||||
|
stats: Dict[str, float]
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate composite relative premium score (0-100).
|
||||||
|
Combines z-score and intraday percentile with median normalization.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
- High z-score (unusual size) = higher score
|
||||||
|
- High intraday percentile (large relative to today's flow) = higher score
|
||||||
|
- Normalize by median to account for symbol-specific scaling
|
||||||
|
"""
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Z-score component (40% weight)
|
||||||
|
# Z-score > 1 = 1 std above mean, Z-score > 2 = 2 std above mean
|
||||||
|
if zscore is not None:
|
||||||
|
# Normalize z-score to 0-40 range (clamp at ±3 sigma)
|
||||||
|
zscore_normalized = max(0, min(40, (zscore + 3) * (40 / 6)))
|
||||||
|
score += zscore_normalized
|
||||||
|
|
||||||
|
# Intraday percentile component (40% weight)
|
||||||
|
if intraday_percentile is not None:
|
||||||
|
# Percentile is already 0-100, scale to 0-40
|
||||||
|
score += (intraday_percentile / 100.0) * 40.0
|
||||||
|
|
||||||
|
# Median normalization component (20% weight)
|
||||||
|
# Premium > median gets bonus, premium < median gets penalty
|
||||||
|
if stats and stats.get('median', 0) > 0:
|
||||||
|
median_ratio = premium / stats['median']
|
||||||
|
# Ratio > 1.5 = strong, ratio > 2.0 = very strong
|
||||||
|
if median_ratio >= 2.0:
|
||||||
|
score += 20.0
|
||||||
|
elif median_ratio >= 1.5:
|
||||||
|
score += 15.0
|
||||||
|
elif median_ratio >= 1.0:
|
||||||
|
score += 10.0
|
||||||
|
elif median_ratio >= 0.5:
|
||||||
|
score += 5.0
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, score))
|
||||||
|
|
||||||
|
async def enrich_with_relative_premium(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Add relative premium metrics to DataFrame.
|
||||||
|
Adds: premium_zscore, premium_percentile_intraday, relative_premium_score
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# Initialize new columns
|
||||||
|
df['premium_zscore'] = None
|
||||||
|
df['premium_percentile_intraday'] = None
|
||||||
|
df['relative_premium_score'] = 0.0
|
||||||
|
|
||||||
|
# Cache historical stats per symbol to avoid repeated queries
|
||||||
|
stats_cache: Dict[str, Dict[str, float]] = {}
|
||||||
|
|
||||||
|
# Group by symbol and date for batch processing
|
||||||
|
unique_symbols = df['symbol_norm'].unique()
|
||||||
|
unique_dates = df['flow_date_cst'].unique()
|
||||||
|
|
||||||
|
logger.info(f"Calculating relative premium scores for {len(unique_symbols)} symbols")
|
||||||
|
|
||||||
|
for symbol in unique_symbols:
|
||||||
|
# Fetch historical stats once per symbol
|
||||||
|
if self.pool:
|
||||||
|
# Use the most recent date for this symbol as reference
|
||||||
|
symbol_dates = df[df['symbol_norm'] == symbol]['flow_date_cst'].unique()
|
||||||
|
if len(symbol_dates) > 0:
|
||||||
|
reference_date = max(symbol_dates)
|
||||||
|
stats = await self.fetch_historical_premium_stats(symbol, reference_date)
|
||||||
|
stats_cache[symbol] = stats
|
||||||
|
else:
|
||||||
|
stats_cache[symbol] = {}
|
||||||
|
else:
|
||||||
|
stats_cache[symbol] = {}
|
||||||
|
|
||||||
|
# Calculate metrics for each row
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
premium = row.get('premium_num')
|
||||||
|
if pd.isna(premium) or premium <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
symbol = row['symbol_norm']
|
||||||
|
flow_date = row.get('flow_date_cst')
|
||||||
|
|
||||||
|
# Get historical stats
|
||||||
|
stats = stats_cache.get(symbol, {})
|
||||||
|
|
||||||
|
# Calculate z-score
|
||||||
|
zscore = self.calculate_zscore(premium, stats)
|
||||||
|
if zscore is not None:
|
||||||
|
df.at[idx, 'premium_zscore'] = float(zscore)
|
||||||
|
|
||||||
|
# Calculate intraday percentile (using current DataFrame subset)
|
||||||
|
intraday_percentile = self.calculate_intraday_percentile(
|
||||||
|
df, symbol, flow_date, premium
|
||||||
|
)
|
||||||
|
if intraday_percentile is not None:
|
||||||
|
df.at[idx, 'premium_percentile_intraday'] = float(intraday_percentile)
|
||||||
|
|
||||||
|
# Calculate composite relative premium score
|
||||||
|
relative_score = self.calculate_relative_premium_score(
|
||||||
|
premium, zscore, intraday_percentile, stats
|
||||||
|
)
|
||||||
|
df.at[idx, 'relative_premium_score'] = float(relative_score)
|
||||||
|
|
||||||
|
logger.info(f"Relative premium scoring complete. Mean score: {df['relative_premium_score'].mean():.2f}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
"""
|
||||||
|
Signal Component Scorer
|
||||||
|
Converts binary badge logic into continuous numeric signal components
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class SignalComponentScorer:
|
||||||
|
"""
|
||||||
|
Replaces binary badge logic (💎 ⭐ 🟢 🔴) with continuous numeric scores.
|
||||||
|
Badges remain display-only, but signal_strength is computed from components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Signal strength weights (must sum to 1.0)
|
||||||
|
self.aggression_weight = 0.30
|
||||||
|
self.size_concentration_weight = 0.30
|
||||||
|
self.repeat_velocity_weight = 0.20
|
||||||
|
self.strike_clustering_weight = 0.20
|
||||||
|
|
||||||
|
def calculate_aggression_score(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Measures trade aggression: premium paid at ask vs bid, ITM vs OTM preference.
|
||||||
|
|
||||||
|
Higher score = more aggressive buying:
|
||||||
|
- ITM premiums indicate willingness to pay intrinsic value
|
||||||
|
- Ask-side trades (AA) indicate market orders
|
||||||
|
- Large premium relative to spot price
|
||||||
|
"""
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
premium_num = row.get('premium_num', 0) or 0
|
||||||
|
spot_num = row.get('spot_num', 0) or 0
|
||||||
|
cp_norm = row.get('cp_norm', '')
|
||||||
|
side = str(row.get('Side', '')).upper()
|
||||||
|
|
||||||
|
# ITM premium component (0-40 points)
|
||||||
|
bull_prem_itm = row.get('bull_prem_itm', 0) or 0
|
||||||
|
bear_prem_itm = row.get('bear_prem_itm', 0) or 0
|
||||||
|
total_prem_itm = bull_prem_itm + bear_prem_itm
|
||||||
|
bull_total = row.get('bull_total', 0) or 0
|
||||||
|
bear_total = row.get('bear_total', 0) or 0
|
||||||
|
total_premium = bull_total + bear_total
|
||||||
|
|
||||||
|
if total_premium > 0:
|
||||||
|
itm_ratio = total_prem_itm / total_premium
|
||||||
|
score += itm_ratio * 40.0
|
||||||
|
|
||||||
|
# Ask-side aggression component (0-30 points)
|
||||||
|
# AA = buying at ask (market orders, urgency)
|
||||||
|
has_aa = 'AA' in side or 'AT_ASK' in side
|
||||||
|
has_bb = 'BB' in side or 'AT_BID' in side
|
||||||
|
|
||||||
|
if has_aa and not has_bb:
|
||||||
|
score += 30.0
|
||||||
|
elif has_aa:
|
||||||
|
score += 15.0
|
||||||
|
|
||||||
|
# Premium-to-spot ratio component (0-30 points)
|
||||||
|
# Large premium relative to spot indicates aggressive positioning
|
||||||
|
if spot_num > 0 and premium_num > 0:
|
||||||
|
prem_to_spot_ratio = premium_num / spot_num
|
||||||
|
# Normalize: 0.01 (1%) = 10 points, 0.05 (5%) = 30 points
|
||||||
|
ratio_score = min(30.0, prem_to_spot_ratio * 600.0)
|
||||||
|
score += ratio_score
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, score))
|
||||||
|
|
||||||
|
def calculate_size_concentration_score(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Measures size concentration: how much premium is concentrated in single strikes/expiries.
|
||||||
|
|
||||||
|
Higher score = more concentrated (single large trade vs many small ones):
|
||||||
|
- High premium in single trade
|
||||||
|
- OI buildup in specific strikes
|
||||||
|
- Directional consistency (all calls or all puts)
|
||||||
|
"""
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
premium_num = row.get('premium_num', 0) or 0
|
||||||
|
bull_total = row.get('bull_total', 0) or 0
|
||||||
|
bear_total = row.get('bear_total', 0) or 0
|
||||||
|
total_premium = bull_total + bear_total
|
||||||
|
|
||||||
|
# Single-trade premium concentration (0-40 points)
|
||||||
|
# Premium of current trade relative to running total
|
||||||
|
if total_premium > 0:
|
||||||
|
concentration_ratio = premium_num / total_premium
|
||||||
|
# Single trade = 50%+ of total = very concentrated
|
||||||
|
if concentration_ratio >= 0.5:
|
||||||
|
score += 40.0
|
||||||
|
elif concentration_ratio >= 0.3:
|
||||||
|
score += 30.0
|
||||||
|
elif concentration_ratio >= 0.2:
|
||||||
|
score += 20.0
|
||||||
|
elif concentration_ratio >= 0.1:
|
||||||
|
score += 10.0
|
||||||
|
|
||||||
|
# OI concentration component (0-30 points)
|
||||||
|
# High OI in OTM strikes indicates concentrated positioning
|
||||||
|
oi_cb_otm = row.get('oi_cb_otm', 0) or 0
|
||||||
|
oi_pb_otm = row.get('oi_pb_otm', 0) or 0
|
||||||
|
oi_all = row.get('oi_all', 0) or 0
|
||||||
|
|
||||||
|
if oi_all > 0:
|
||||||
|
otm_oi_ratio = (oi_cb_otm + oi_pb_otm) / oi_all
|
||||||
|
score += otm_oi_ratio * 30.0
|
||||||
|
|
||||||
|
# Directional consistency (0-30 points)
|
||||||
|
# Pure directional flow (all bull or all bear) = more concentrated
|
||||||
|
if total_premium > 0:
|
||||||
|
direction_strength = abs(bull_total - bear_total) / total_premium
|
||||||
|
score += direction_strength * 30.0
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, score))
|
||||||
|
|
||||||
|
def calculate_repeat_trade_velocity(self, df: pd.DataFrame, row_idx: int) -> float:
|
||||||
|
"""
|
||||||
|
Measures repeat trade velocity: frequency of trades in same direction/expiry.
|
||||||
|
|
||||||
|
Higher score = faster follow-on trades (urgency building):
|
||||||
|
- Time between consecutive trades decreasing
|
||||||
|
- Multiple trades in same direction
|
||||||
|
- Same expiry date (rolling accumulation)
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
direction = current_row.get('direction')
|
||||||
|
exp_date = current_row.get('exp_date')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Look back at recent trades for same symbol
|
||||||
|
# Sort by timestamp to find preceding trades
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
current_idx = df_sorted.index[df_sorted.index == row_idx]
|
||||||
|
|
||||||
|
if len(current_idx) == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
current_pos = current_idx[0]
|
||||||
|
|
||||||
|
# Look at last 10 trades for this symbol
|
||||||
|
symbol_mask = (df_sorted['symbol_norm'] == symbol) & \
|
||||||
|
(df_sorted.index < current_pos) & \
|
||||||
|
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
|
||||||
|
|
||||||
|
recent_trades = df_sorted[symbol_mask].tail(10)
|
||||||
|
|
||||||
|
if len(recent_trades) == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Time compression component (0-40 points)
|
||||||
|
# Decreasing time gaps = urgency building
|
||||||
|
if len(recent_trades) >= 2:
|
||||||
|
time_gaps = []
|
||||||
|
prev_ts = None
|
||||||
|
for _, trade in recent_trades.iterrows():
|
||||||
|
ts = trade.get('flow_ts_utc')
|
||||||
|
if pd.notna(ts) and prev_ts:
|
||||||
|
gap_minutes = (ts - prev_ts).total_seconds() / 60.0
|
||||||
|
if gap_minutes > 0:
|
||||||
|
time_gaps.append(gap_minutes)
|
||||||
|
prev_ts = ts
|
||||||
|
|
||||||
|
if len(time_gaps) >= 2:
|
||||||
|
# Check if gaps are decreasing (accelerating)
|
||||||
|
recent_gaps = time_gaps[-2:]
|
||||||
|
if recent_gaps[1] < recent_gaps[0]:
|
||||||
|
compression_ratio = recent_gaps[1] / recent_gaps[0] if recent_gaps[0] > 0 else 0
|
||||||
|
# 50% compression = 20 points, 80% compression = 40 points
|
||||||
|
score += (1.0 - compression_ratio) * 40.0
|
||||||
|
|
||||||
|
# Directional consistency (0-35 points)
|
||||||
|
# Same direction trades = building position
|
||||||
|
same_direction = recent_trades[recent_trades['direction'] == direction]
|
||||||
|
if len(recent_trades) > 0:
|
||||||
|
consistency_ratio = len(same_direction) / len(recent_trades)
|
||||||
|
score += consistency_ratio * 35.0
|
||||||
|
|
||||||
|
# Expiry concentration (0-25 points)
|
||||||
|
# Same expiry = rolling accumulation
|
||||||
|
if pd.notna(exp_date):
|
||||||
|
same_expiry = recent_trades[recent_trades['exp_date'] == exp_date]
|
||||||
|
if len(recent_trades) > 0:
|
||||||
|
expiry_ratio = len(same_expiry) / len(recent_trades)
|
||||||
|
score += expiry_ratio * 25.0
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, score))
|
||||||
|
|
||||||
|
def calculate_strike_clustering_score(self, df: pd.DataFrame, row_idx: int) -> float:
|
||||||
|
"""
|
||||||
|
Measures strike clustering: trades accumulating at specific strike levels.
|
||||||
|
|
||||||
|
Higher score = more clustering (laddering or concentrated strikes):
|
||||||
|
- Multiple trades at same strike
|
||||||
|
- Sequential strikes (laddering)
|
||||||
|
- Strikes near current price (pin risk)
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
strike_num = current_row.get('strike_num')
|
||||||
|
spot_num = current_row.get('spot_num', 0) or 0
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
exp_date = current_row.get('exp_date')
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(strike_num) or pd.isna(flow_ts_utc):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Look at recent trades for same symbol and expiry
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
symbol_mask = (df_sorted['symbol_norm'] == symbol) & \
|
||||||
|
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
|
||||||
|
|
||||||
|
if pd.notna(exp_date):
|
||||||
|
symbol_mask = symbol_mask & (df_sorted['exp_date'] == exp_date)
|
||||||
|
|
||||||
|
recent_trades = df_sorted[symbol_mask].tail(20)
|
||||||
|
|
||||||
|
if len(recent_trades) <= 1:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Exact strike clustering (0-40 points)
|
||||||
|
# Multiple trades at same strike
|
||||||
|
same_strike_count = len(recent_trades[recent_trades['strike_num'] == strike_num])
|
||||||
|
if len(recent_trades) > 0:
|
||||||
|
clustering_ratio = same_strike_count / len(recent_trades)
|
||||||
|
score += clustering_ratio * 40.0
|
||||||
|
|
||||||
|
# Strike laddering detection (0-35 points)
|
||||||
|
# Sequential strikes (e.g., 100, 105, 110) indicate laddering
|
||||||
|
unique_strikes = sorted(recent_trades['strike_num'].dropna().unique())
|
||||||
|
if len(unique_strikes) >= 3:
|
||||||
|
# Check if strikes are roughly evenly spaced (laddering)
|
||||||
|
if spot_num > 0:
|
||||||
|
strike_diffs = [abs(unique_strikes[i+1] - unique_strikes[i]) for i in range(len(unique_strikes)-1)]
|
||||||
|
if len(strike_diffs) > 0:
|
||||||
|
avg_diff = np.mean(strike_diffs)
|
||||||
|
std_diff = np.std(strike_diffs)
|
||||||
|
# Low std relative to mean = regular spacing (laddering)
|
||||||
|
if avg_diff > 0:
|
||||||
|
regularity = 1.0 - min(1.0, std_diff / avg_diff)
|
||||||
|
score += regularity * 35.0
|
||||||
|
|
||||||
|
# Proximity to spot (0-25 points)
|
||||||
|
# Strikes near spot price = pin risk, more significant
|
||||||
|
if spot_num > 0:
|
||||||
|
strike_pct_diff = abs(strike_num - spot_num) / spot_num * 100.0
|
||||||
|
# Within 2% = 25 points, within 5% = 15 points, within 10% = 5 points
|
||||||
|
if strike_pct_diff <= 2.0:
|
||||||
|
score += 25.0
|
||||||
|
elif strike_pct_diff <= 5.0:
|
||||||
|
score += 15.0
|
||||||
|
elif strike_pct_diff <= 10.0:
|
||||||
|
score += 5.0
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, score))
|
||||||
|
|
||||||
|
def calculate_signal_strength(self, row: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculate composite signal_strength from component scores.
|
||||||
|
|
||||||
|
signal_strength =
|
||||||
|
0.30 * aggression_score +
|
||||||
|
0.30 * size_concentration_score +
|
||||||
|
0.20 * repeat_trade_velocity +
|
||||||
|
0.20 * strike_clustering_score
|
||||||
|
"""
|
||||||
|
aggression = row.get('aggression_score', 0) or 0
|
||||||
|
size_conc = row.get('size_concentration_score', 0) or 0
|
||||||
|
repeat_vel = row.get('repeat_trade_velocity', 0) or 0
|
||||||
|
strike_clust = row.get('strike_clustering_score', 0) or 0
|
||||||
|
|
||||||
|
signal_strength = (
|
||||||
|
self.aggression_weight * aggression +
|
||||||
|
self.size_concentration_weight * size_conc +
|
||||||
|
self.repeat_velocity_weight * repeat_vel +
|
||||||
|
self.strike_clustering_weight * strike_clust
|
||||||
|
)
|
||||||
|
|
||||||
|
return min(100.0, max(0.0, signal_strength))
|
||||||
|
|
||||||
|
def enrich_with_signal_components(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Add signal component scores to DataFrame.
|
||||||
|
Adds: aggression_score, size_concentration_score, repeat_trade_velocity,
|
||||||
|
strike_clustering_score, signal_strength
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# Initialize columns
|
||||||
|
df['aggression_score'] = 0.0
|
||||||
|
df['size_concentration_score'] = 0.0
|
||||||
|
df['repeat_trade_velocity'] = 0.0
|
||||||
|
df['strike_clustering_score'] = 0.0
|
||||||
|
df['signal_strength'] = 0.0
|
||||||
|
|
||||||
|
# Calculate component scores (some require DataFrame context)
|
||||||
|
df['aggression_score'] = df.apply(self.calculate_aggression_score, axis=1)
|
||||||
|
df['size_concentration_score'] = df.apply(self.calculate_size_concentration_score, axis=1)
|
||||||
|
|
||||||
|
# These require DataFrame context, so iterate
|
||||||
|
for idx in df.index:
|
||||||
|
df.at[idx, 'repeat_trade_velocity'] = self.calculate_repeat_trade_velocity(df, idx)
|
||||||
|
df.at[idx, 'strike_clustering_score'] = self.calculate_strike_clustering_score(df, idx)
|
||||||
|
|
||||||
|
# Calculate composite signal_strength
|
||||||
|
df['signal_strength'] = df.apply(self.calculate_signal_strength, axis=1)
|
||||||
|
|
||||||
|
logger.info(f"Signal component scoring complete. Mean signal_strength: {df['signal_strength'].mean():.2f}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
"""
|
||||||
|
Time-Sequenced Flow Analysis Service
|
||||||
|
Analyzes flow patterns over time to detect urgency, distribution, and continuation signals
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from datetime import timedelta
|
||||||
|
from utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class TimeSequencedAnalyzer:
|
||||||
|
"""
|
||||||
|
Analyzes flow patterns over time to detect:
|
||||||
|
- Flow acceleration (urgency building)
|
||||||
|
- Distribution patterns (flow weakening)
|
||||||
|
- Strike laddering (sequential accumulation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Configuration
|
||||||
|
self.analysis_window_minutes = 60 # Look back window for flow analysis
|
||||||
|
self.min_trades_for_analysis = 3 # Minimum trades needed for meaningful analysis
|
||||||
|
|
||||||
|
def calculate_flow_acceleration(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate flow acceleration: change in premium per minute.
|
||||||
|
Positive = accelerating (urgency building)
|
||||||
|
Negative = decelerating (flow weakening)
|
||||||
|
|
||||||
|
Returns: Δ premium / minute (premium rate change)
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
direction = current_row.get('direction')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort by timestamp
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
|
||||||
|
# Look at recent trades for same symbol and direction
|
||||||
|
window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes)
|
||||||
|
|
||||||
|
mask = (
|
||||||
|
(df_sorted['symbol_norm'] == symbol) &
|
||||||
|
(df_sorted['direction'] == direction) &
|
||||||
|
(df_sorted['flow_ts_utc'] >= window_start) &
|
||||||
|
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_trades = df_sorted[mask]
|
||||||
|
|
||||||
|
if len(recent_trades) < self.min_trades_for_analysis:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate premium accumulation over time
|
||||||
|
recent_trades = recent_trades.sort_values('flow_ts_utc')
|
||||||
|
recent_trades = recent_trades.copy()
|
||||||
|
recent_trades['cumulative_premium'] = recent_trades['premium_num'].cumsum()
|
||||||
|
|
||||||
|
# Fit linear trend to cumulative premium vs time
|
||||||
|
# Convert timestamps to minutes since window start
|
||||||
|
recent_trades['minutes_since_start'] = (
|
||||||
|
recent_trades['flow_ts_utc'] - window_start
|
||||||
|
).dt.total_seconds() / 60.0
|
||||||
|
|
||||||
|
# Calculate slope (premium per minute)
|
||||||
|
if len(recent_trades) >= 2:
|
||||||
|
x = recent_trades['minutes_since_start'].values
|
||||||
|
y = recent_trades['cumulative_premium'].values
|
||||||
|
|
||||||
|
# Linear regression: y = mx + b, we want m (slope)
|
||||||
|
# Calculate acceleration as change in slope (second derivative approximation)
|
||||||
|
if len(recent_trades) >= 3:
|
||||||
|
# Split into two halves
|
||||||
|
mid_idx = len(recent_trades) // 2
|
||||||
|
first_half = recent_trades.iloc[:mid_idx]
|
||||||
|
second_half = recent_trades.iloc[mid_idx:]
|
||||||
|
|
||||||
|
if len(first_half) >= 2 and len(second_half) >= 2:
|
||||||
|
# Calculate slopes for each half
|
||||||
|
x1 = first_half['minutes_since_start'].values
|
||||||
|
y1 = first_half['cumulative_premium'].values
|
||||||
|
slope1 = np.polyfit(x1, y1, 1)[0] if len(x1) > 1 else 0
|
||||||
|
|
||||||
|
x2 = second_half['minutes_since_start'].values
|
||||||
|
y2 = second_half['cumulative_premium'].values
|
||||||
|
slope2 = np.polyfit(x2, y2, 1)[0] if len(x2) > 1 else 0
|
||||||
|
|
||||||
|
# Acceleration = change in slope
|
||||||
|
acceleration = slope2 - slope1
|
||||||
|
return float(acceleration)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calculate_time_between_hits(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate average time between consecutive trades (in minutes).
|
||||||
|
Lower = faster pace, higher urgency.
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
direction = current_row.get('direction')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort by timestamp
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
|
||||||
|
# Look at recent trades
|
||||||
|
window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes)
|
||||||
|
|
||||||
|
mask = (
|
||||||
|
(df_sorted['symbol_norm'] == symbol) &
|
||||||
|
(df_sorted['direction'] == direction) &
|
||||||
|
(df_sorted['flow_ts_utc'] >= window_start) &
|
||||||
|
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_trades = df_sorted[mask].sort_values('flow_ts_utc')
|
||||||
|
|
||||||
|
if len(recent_trades) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate time gaps between consecutive trades
|
||||||
|
time_gaps = []
|
||||||
|
prev_ts = None
|
||||||
|
for _, trade in recent_trades.iterrows():
|
||||||
|
ts = trade.get('flow_ts_utc')
|
||||||
|
if pd.notna(ts) and prev_ts:
|
||||||
|
gap_minutes = (ts - prev_ts).total_seconds() / 60.0
|
||||||
|
if gap_minutes > 0:
|
||||||
|
time_gaps.append(gap_minutes)
|
||||||
|
prev_ts = ts
|
||||||
|
|
||||||
|
if len(time_gaps) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
avg_time_between = np.mean(time_gaps)
|
||||||
|
return float(avg_time_between)
|
||||||
|
|
||||||
|
def calculate_follow_on_ratio(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate follow-on ratio: fraction of trades in same direction after initial trade.
|
||||||
|
Higher = continuation, Lower = reversal/distribution.
|
||||||
|
|
||||||
|
Returns: ratio of same-direction trades / total trades (0-1)
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
direction = current_row.get('direction')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort by timestamp
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
|
||||||
|
# Look at trades after this one (within window)
|
||||||
|
window_end = flow_ts_utc + timedelta(minutes=self.analysis_window_minutes)
|
||||||
|
|
||||||
|
mask = (
|
||||||
|
(df_sorted['symbol_norm'] == symbol) &
|
||||||
|
(df_sorted['flow_ts_utc'] > flow_ts_utc) &
|
||||||
|
(df_sorted['flow_ts_utc'] <= window_end)
|
||||||
|
)
|
||||||
|
|
||||||
|
follow_on_trades = df_sorted[mask]
|
||||||
|
|
||||||
|
if len(follow_on_trades) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Count same-direction vs opposite-direction
|
||||||
|
same_direction = follow_on_trades[follow_on_trades['direction'] == direction]
|
||||||
|
follow_on_ratio = len(same_direction) / len(follow_on_trades) if len(follow_on_trades) > 0 else 0.0
|
||||||
|
|
||||||
|
return float(follow_on_ratio)
|
||||||
|
|
||||||
|
def detect_strike_laddering(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
row_idx: int
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Detect strike laddering: sequential strikes in same direction.
|
||||||
|
Returns True if laddering pattern detected.
|
||||||
|
"""
|
||||||
|
if row_idx >= len(df):
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_row = df.iloc[row_idx]
|
||||||
|
symbol = current_row.get('symbol_norm')
|
||||||
|
direction = current_row.get('direction')
|
||||||
|
cp_norm = current_row.get('cp_norm')
|
||||||
|
flow_ts_utc = current_row.get('flow_ts_utc')
|
||||||
|
exp_date = current_row.get('exp_date')
|
||||||
|
|
||||||
|
if pd.isna(symbol) or pd.isna(direction) or pd.isna(cp_norm):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Sort by timestamp
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
|
||||||
|
# Look at recent trades
|
||||||
|
window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes)
|
||||||
|
|
||||||
|
mask = (
|
||||||
|
(df_sorted['symbol_norm'] == symbol) &
|
||||||
|
(df_sorted['direction'] == direction) &
|
||||||
|
(df_sorted['cp_norm'] == cp_norm) &
|
||||||
|
(df_sorted['flow_ts_utc'] >= window_start) &
|
||||||
|
(df_sorted['flow_ts_utc'] <= flow_ts_utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
if pd.notna(exp_date):
|
||||||
|
mask = mask & (df_sorted['exp_date'] == exp_date)
|
||||||
|
|
||||||
|
recent_trades = df_sorted[mask].sort_values('flow_ts_utc')
|
||||||
|
|
||||||
|
if len(recent_trades) < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get unique strikes in order
|
||||||
|
strikes = recent_trades['strike_num'].dropna().unique()
|
||||||
|
strikes = sorted(strikes)
|
||||||
|
|
||||||
|
if len(strikes) < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if strikes are sequential (laddering)
|
||||||
|
# Look for consistent spacing (e.g., 100, 105, 110 or 50, 55, 60)
|
||||||
|
strike_diffs = [strikes[i+1] - strikes[i] for i in range(len(strikes)-1)]
|
||||||
|
|
||||||
|
if len(strike_diffs) >= 2:
|
||||||
|
# Check if differences are roughly equal (within 20% variance)
|
||||||
|
avg_diff = np.mean(strike_diffs)
|
||||||
|
if avg_diff > 0:
|
||||||
|
std_diff = np.std(strike_diffs)
|
||||||
|
cv = std_diff / avg_diff if avg_diff > 0 else float('inf') # Coefficient of variation
|
||||||
|
|
||||||
|
# Low coefficient of variation = regular spacing = laddering
|
||||||
|
if cv < 0.2: # Less than 20% variation
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def enrich_with_time_sequenced_metrics(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Add time-sequenced flow metrics to DataFrame.
|
||||||
|
Adds: flow_acceleration, time_between_hits, follow_on_ratio, strike_laddering_detected
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# Initialize columns
|
||||||
|
df['flow_acceleration'] = None
|
||||||
|
df['time_between_hits'] = None
|
||||||
|
df['follow_on_ratio'] = None
|
||||||
|
df['strike_laddering_detected'] = False
|
||||||
|
|
||||||
|
# Sort by timestamp for proper analysis
|
||||||
|
df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True)
|
||||||
|
|
||||||
|
# Calculate metrics for each row
|
||||||
|
for idx in df_sorted.index:
|
||||||
|
original_idx = df_sorted.index[idx]
|
||||||
|
|
||||||
|
# Flow acceleration
|
||||||
|
acceleration = self.calculate_flow_acceleration(df_sorted, idx)
|
||||||
|
if acceleration is not None:
|
||||||
|
df.at[original_idx, 'flow_acceleration'] = float(acceleration)
|
||||||
|
|
||||||
|
# Time between hits
|
||||||
|
time_between = self.calculate_time_between_hits(df_sorted, idx)
|
||||||
|
if time_between is not None:
|
||||||
|
df.at[original_idx, 'time_between_hits'] = float(time_between)
|
||||||
|
|
||||||
|
# Follow-on ratio
|
||||||
|
follow_on = self.calculate_follow_on_ratio(df_sorted, idx)
|
||||||
|
if follow_on is not None:
|
||||||
|
df.at[original_idx, 'follow_on_ratio'] = float(follow_on)
|
||||||
|
|
||||||
|
# Strike laddering
|
||||||
|
laddering = self.detect_strike_laddering(df_sorted, idx)
|
||||||
|
df.at[original_idx, 'strike_laddering_detected'] = bool(laddering)
|
||||||
|
|
||||||
|
logger.info(f"Time-sequenced analysis complete. Strike laddering detected: {df['strike_laddering_detected'].sum()}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
@ -3,7 +3,13 @@
|
||||||
|
|
||||||
# Activate virtual environment if it exists
|
# Activate virtual environment if it exists
|
||||||
if [ -d "venv" ]; then
|
if [ -d "venv" ]; then
|
||||||
source venv/bin/activate
|
# Try Windows Scripts path first (Git Bash on Windows)
|
||||||
|
if [ -f "venv/Scripts/activate" ]; then
|
||||||
|
source venv/Scripts/activate
|
||||||
|
# Fallback to Unix bin path (Linux/macOS)
|
||||||
|
elif [ -f "venv/bin/activate" ]; then
|
||||||
|
source venv/bin/activate
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start the service
|
# Start the service
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { backtestSignal } from '../services/backtester.js';
|
import { backtestSignal } from '../services/backtester.js';
|
||||||
|
import { rawQuery } from '../db.js';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -27,9 +28,6 @@ router.post('/run', async (req, res) => {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
lookbackDays = 30,
|
lookbackDays = 30,
|
||||||
exitStrategy = 'target_stop',
|
|
||||||
targetPct = 1.5,
|
|
||||||
stopPct = 1.5,
|
|
||||||
minOccurrences = 10
|
minOccurrences = 10
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
|
@ -298,5 +296,646 @@ router.get('/presets', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/backtest/date-analysis
|
||||||
|
* Get symbols with rocket scores 2 or 3 on a specific date
|
||||||
|
*/
|
||||||
|
router.get('/date-analysis', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, minRocketScore = 2, maxRocketScore = 3, minPremium = 80000 } = req.query;
|
||||||
|
|
||||||
|
// Parse numeric parameters
|
||||||
|
const minRocket = parseFloat(minRocketScore) || 2;
|
||||||
|
const maxRocket = parseFloat(maxRocketScore) || 3;
|
||||||
|
const minPrem = parseFloat(minPremium) || 80000;
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Date parameter is required (YYYY-MM-DD)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query to get symbols with rocket scores 2-3 on the date
|
||||||
|
const query = `
|
||||||
|
WITH base AS (
|
||||||
|
SELECT
|
||||||
|
ofm.ctid AS rid,
|
||||||
|
UPPER(TRIM(ofm."Symbol")) AS symbol_norm,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Spot"::text, '[\\s$,]', '', 'g'),'')::numeric AS spot_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Premium"::text, '[\\s$,]', '', 'g'),'')::numeric AS premium_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Volume"::text, '[\\s$,]', '', 'g'),'')::numeric AS vol_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."OI"::text, '[\\s$,]', '', 'g'),'')::numeric AS oi_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Strike"::text, '[\\s$,]', '', 'g'),'')::numeric AS strike_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Dte"::text, '[\\s$,]', '', 'g'),'')::numeric AS dte_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."ImpliedVolatility"::text, '[\\s$,]', '', 'g'),'')::numeric AS iv_num,
|
||||||
|
CASE WHEN UPPER(TRIM(ofm."CallPut")) IN ('C','CALL','CALLS','CE') THEN 'CALL'
|
||||||
|
WHEN UPPER(TRIM(ofm."CallPut")) IN ('P','PUT','PUTS','PE') THEN 'PUT'
|
||||||
|
END AS cp_norm,
|
||||||
|
CASE
|
||||||
|
WHEN UPPER(TRIM(ofm."Side")) IN ('A','AA','ASK','BUY','BOT','BTO','AT_ASK') THEN 'BUY'
|
||||||
|
WHEN UPPER(TRIM(ofm."Side")) IN ('B','BB','BID','SELL','SLD','STO','AT_BID') THEN 'SELL'
|
||||||
|
ELSE NULL
|
||||||
|
END AS side_norm,
|
||||||
|
CASE
|
||||||
|
WHEN ofm."CreatedDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN
|
||||||
|
to_timestamp(ofm."CreatedDate"::text || ' ' || COALESCE(btrim(ofm."CreatedTime"::text), '00:00:00'), 'YYYY-MM-DD HH24:MI:SS')
|
||||||
|
ELSE to_timestamp(ofm."CreatedDate"::text || ' ' || COALESCE(btrim(ofm."CreatedTime"::text), '00:00:00'), 'MM/DD/YYYY HH24:MI:SS')
|
||||||
|
END AS flow_ts_local,
|
||||||
|
CASE
|
||||||
|
WHEN ofm."ExpirationDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN ofm."ExpirationDate"::date
|
||||||
|
WHEN ofm."ExpirationDate"::text ~ '^\\d{1,2}/\\d{1,2}/\\d{2,4}$' THEN to_date(ofm."ExpirationDate"::text, 'MM/DD/YYYY')
|
||||||
|
ELSE NULL
|
||||||
|
END AS exp_date
|
||||||
|
FROM public."OptionsFlow_monthly" ofm
|
||||||
|
WHERE ofm."Premium" IS NOT NULL
|
||||||
|
AND btrim(ofm."Premium"::text) <> ''
|
||||||
|
AND ofm."StockEtf" = 'STOCK'
|
||||||
|
AND (
|
||||||
|
CASE
|
||||||
|
WHEN ofm."CreatedDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$'
|
||||||
|
THEN ofm."CreatedDate"::date
|
||||||
|
WHEN ofm."CreatedDate"::text ~ '^\\d{1,2}/\\d{1,2}/\\d{2,4}$'
|
||||||
|
THEN to_date(ofm."CreatedDate"::text, 'MM/DD/YYYY')
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
) = $1::date
|
||||||
|
),
|
||||||
|
enriched AS (
|
||||||
|
SELECT
|
||||||
|
b.*,
|
||||||
|
-- Calculate moneyness (ITM/OTM percentage)
|
||||||
|
CASE
|
||||||
|
WHEN b.strike_num IS NOT NULL AND b.spot_num IS NOT NULL AND b.spot_num > 0 THEN
|
||||||
|
CASE
|
||||||
|
WHEN b.cp_norm = 'CALL' THEN ((b.strike_num - b.spot_num) / b.spot_num) * 100
|
||||||
|
WHEN b.cp_norm = 'PUT' THEN ((b.spot_num - b.strike_num) / b.spot_num) * 100
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
ELSE NULL
|
||||||
|
END AS mny_pct,
|
||||||
|
-- Calculate DTE if not provided
|
||||||
|
CASE
|
||||||
|
WHEN b.dte_num IS NOT NULL THEN b.dte_num
|
||||||
|
WHEN b.exp_date IS NOT NULL AND b.flow_ts_local IS NOT NULL THEN
|
||||||
|
(b.exp_date::date - b.flow_ts_local::date)::integer
|
||||||
|
ELSE NULL
|
||||||
|
END AS dte_calc,
|
||||||
|
-- DTE bucket classification
|
||||||
|
CASE
|
||||||
|
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 3 THEN '0-3 DTE (urgent)'
|
||||||
|
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 7 THEN '4-7 DTE (short)'
|
||||||
|
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 30 THEN '8-30 DTE (medium)'
|
||||||
|
WHEN b.dte_num IS NOT NULL THEN '30+ DTE (long)'
|
||||||
|
WHEN b.exp_date IS NOT NULL AND b.flow_ts_local IS NOT NULL THEN
|
||||||
|
CASE
|
||||||
|
WHEN (b.exp_date::date - b.flow_ts_local::date)::integer <= 3 THEN '0-3 DTE (urgent)'
|
||||||
|
WHEN (b.exp_date::date - b.flow_ts_local::date)::integer <= 7 THEN '4-7 DTE (short)'
|
||||||
|
WHEN (b.exp_date::date - b.flow_ts_local::date)::integer <= 30 THEN '8-30 DTE (medium)'
|
||||||
|
ELSE '30+ DTE (long)'
|
||||||
|
END
|
||||||
|
ELSE NULL
|
||||||
|
END AS dte_bucket,
|
||||||
|
-- Volume/OI ratio
|
||||||
|
CASE
|
||||||
|
WHEN b.oi_num > 0 THEN b.vol_num / NULLIF(b.oi_num, 0)
|
||||||
|
ELSE NULL
|
||||||
|
END AS vol_oi_ratio,
|
||||||
|
-- ITM/OTM classification
|
||||||
|
CASE
|
||||||
|
WHEN b.strike_num IS NOT NULL AND b.spot_num IS NOT NULL THEN
|
||||||
|
CASE
|
||||||
|
WHEN b.cp_norm = 'CALL' AND b.strike_num <= b.spot_num THEN 'ITM'
|
||||||
|
WHEN b.cp_norm = 'PUT' AND b.strike_num >= b.spot_num THEN 'ITM'
|
||||||
|
ELSE 'OTM'
|
||||||
|
END
|
||||||
|
ELSE NULL
|
||||||
|
END AS moneyness_class
|
||||||
|
FROM base b
|
||||||
|
),
|
||||||
|
flow AS (
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
CASE
|
||||||
|
WHEN (e.cp_norm='CALL' AND e.side_norm='BUY') OR (e.cp_norm='PUT' AND e.side_norm='SELL') THEN 'BULL'
|
||||||
|
WHEN (e.cp_norm='PUT' AND e.side_norm='BUY') OR (e.cp_norm='CALL' AND e.side_norm='SELL') THEN 'BEAR'
|
||||||
|
ELSE NULL
|
||||||
|
END AS direction,
|
||||||
|
CASE
|
||||||
|
WHEN EXTRACT(HOUR FROM e.flow_ts_local)::int BETWEEN 4 AND 9
|
||||||
|
AND NOT (EXTRACT(HOUR FROM e.flow_ts_local)::int = 9 AND EXTRACT(MINUTE FROM e.flow_ts_local)::int >= 30) THEN 'PRE'
|
||||||
|
WHEN (EXTRACT(HOUR FROM e.flow_ts_local)::int = 9 AND EXTRACT(MINUTE FROM e.flow_ts_local)::int >= 30)
|
||||||
|
OR (EXTRACT(HOUR FROM e.flow_ts_local)::int > 9 AND EXTRACT(HOUR FROM e.flow_ts_local)::int < 16) THEN 'RTH'
|
||||||
|
WHEN EXTRACT(HOUR FROM e.flow_ts_local)::int BETWEEN 16 AND 20 THEN 'POST'
|
||||||
|
ELSE 'OFF'
|
||||||
|
END AS session_bucket
|
||||||
|
FROM enriched e
|
||||||
|
),
|
||||||
|
catalyst_check AS (
|
||||||
|
SELECT
|
||||||
|
f.*,
|
||||||
|
CASE WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public."AlertStream_monthly" a
|
||||||
|
WHERE UPPER(TRIM(a."Ticker")) = f.symbol_norm
|
||||||
|
OR UPPER(TRIM(a."ticker")) = f.symbol_norm
|
||||||
|
OR UPPER(TRIM(a."Symbol")) = f.symbol_norm
|
||||||
|
OR UPPER(TRIM(a."symbol")) = f.symbol_norm
|
||||||
|
AND (
|
||||||
|
CASE
|
||||||
|
WHEN a."Date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."Date"::date
|
||||||
|
WHEN a."date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."date"::date
|
||||||
|
WHEN a."AlertDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."AlertDate"::date
|
||||||
|
WHEN a."alert_date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."alert_date"::date
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
) = $1::date
|
||||||
|
) THEN 1 ELSE 0 END AS catalyst_flag
|
||||||
|
FROM flow f
|
||||||
|
),
|
||||||
|
scored AS (
|
||||||
|
SELECT
|
||||||
|
c.*,
|
||||||
|
(CASE
|
||||||
|
WHEN c.premium_num >= 2000000 THEN 3.0
|
||||||
|
WHEN c.premium_num >= 800000 THEN 2.0
|
||||||
|
WHEN c.premium_num >= 200000 THEN 1.0
|
||||||
|
ELSE 0.0
|
||||||
|
END)
|
||||||
|
+ 1.2 * CASE WHEN COALESCE(c.vol_num,0) > COALESCE(c.oi_num,0) THEN 1 ELSE 0 END
|
||||||
|
+ 1.0 * CASE c.session_bucket WHEN 'RTH' THEN 1 WHEN 'POST' THEN 0.5 WHEN 'PRE' THEN 0.3 ELSE 0 END
|
||||||
|
+ 1.0 * c.catalyst_flag
|
||||||
|
+ 0.8 * CASE WHEN c.moneyness_class = 'OTM' THEN 1 ELSE 0 END
|
||||||
|
AS rocket_score
|
||||||
|
FROM catalyst_check c
|
||||||
|
WHERE c.premium_num >= $2
|
||||||
|
AND c.direction IS NOT NULL
|
||||||
|
AND c.spot_num IS NOT NULL
|
||||||
|
),
|
||||||
|
strike_clusters AS (
|
||||||
|
SELECT
|
||||||
|
symbol_norm,
|
||||||
|
MAX(trades_at_strike) as max_trades_at_single_strike
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
symbol_norm,
|
||||||
|
strike_num,
|
||||||
|
COUNT(*) as trades_at_strike
|
||||||
|
FROM scored
|
||||||
|
WHERE strike_num IS NOT NULL
|
||||||
|
GROUP BY symbol_norm, strike_num
|
||||||
|
) sc
|
||||||
|
GROUP BY symbol_norm
|
||||||
|
),
|
||||||
|
dte_buckets AS (
|
||||||
|
SELECT
|
||||||
|
symbol_norm,
|
||||||
|
dte_bucket,
|
||||||
|
COUNT(*) as bucket_count,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY symbol_norm ORDER BY COUNT(*) DESC) as rn
|
||||||
|
FROM scored
|
||||||
|
WHERE dte_bucket IS NOT NULL
|
||||||
|
GROUP BY symbol_norm, dte_bucket
|
||||||
|
),
|
||||||
|
symbol_agg AS (
|
||||||
|
SELECT
|
||||||
|
s.symbol_norm,
|
||||||
|
COUNT(*) as total_trades, -- Total option trades (individual flow records)
|
||||||
|
COUNT(DISTINCT DATE_TRUNC('hour', s.flow_ts_local) +
|
||||||
|
(EXTRACT(MINUTE FROM s.flow_ts_local)::int / 15) * INTERVAL '15 minutes') as signal_periods, -- Distinct 15-min periods
|
||||||
|
SUM(s.premium_num) as total_premium,
|
||||||
|
MAX(s.rocket_score) as max_rocket_score,
|
||||||
|
MIN(s.rocket_score) as min_rocket_score,
|
||||||
|
AVG(s.rocket_score) as avg_rocket_score,
|
||||||
|
MAX(s.spot_num) as entry_price,
|
||||||
|
STRING_AGG(DISTINCT s.direction, ', ') as directions,
|
||||||
|
STRING_AGG(DISTINCT s.session_bucket, ', ') as sessions,
|
||||||
|
-- BULL signals aggregation
|
||||||
|
COUNT(*) FILTER (WHERE s.direction = 'BULL') as bull_count,
|
||||||
|
COALESCE(SUM(s.premium_num) FILTER (WHERE s.direction = 'BULL'), 0) as bull_premium,
|
||||||
|
COALESCE(MIN(s.premium_num) FILTER (WHERE s.direction = 'BULL'), 0) as bull_min_premium,
|
||||||
|
COALESCE(MAX(s.premium_num) FILTER (WHERE s.direction = 'BULL'), 0) as bull_max_premium,
|
||||||
|
-- BEAR signals aggregation
|
||||||
|
COUNT(*) FILTER (WHERE s.direction = 'BEAR') as bear_count,
|
||||||
|
COALESCE(SUM(s.premium_num) FILTER (WHERE s.direction = 'BEAR'), 0) as bear_premium,
|
||||||
|
COALESCE(MIN(s.premium_num) FILTER (WHERE s.direction = 'BEAR'), 0) as bear_min_premium,
|
||||||
|
COALESCE(MAX(s.premium_num) FILTER (WHERE s.direction = 'BEAR'), 0) as bear_max_premium,
|
||||||
|
-- Predictive factors aggregation
|
||||||
|
-- Moneyness (ITM vs OTM)
|
||||||
|
COUNT(*) FILTER (WHERE s.moneyness_class = 'ITM') as itm_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.moneyness_class = 'OTM') as otm_count,
|
||||||
|
AVG(s.mny_pct) FILTER (WHERE s.mny_pct IS NOT NULL) as avg_moneyness_pct,
|
||||||
|
-- DTE analysis
|
||||||
|
AVG(s.dte_calc) FILTER (WHERE s.dte_calc IS NOT NULL) as avg_dte,
|
||||||
|
db.dte_bucket as most_common_dte_bucket,
|
||||||
|
-- Session analysis
|
||||||
|
COUNT(*) FILTER (WHERE s.session_bucket = 'RTH') as rth_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.session_bucket = 'PRE') as pre_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.session_bucket = 'POST') as post_count,
|
||||||
|
-- Catalyst detection
|
||||||
|
COUNT(*) FILTER (WHERE s.catalyst_flag = 1) as catalyst_count,
|
||||||
|
CASE WHEN COUNT(*) > 0 THEN
|
||||||
|
ROUND(100.0 * COUNT(*) FILTER (WHERE s.catalyst_flag = 1) / COUNT(*), 1)
|
||||||
|
ELSE 0 END as pct_with_catalyst,
|
||||||
|
-- Volume/OI ratio
|
||||||
|
AVG(s.vol_oi_ratio) FILTER (WHERE s.vol_oi_ratio IS NOT NULL) as avg_vol_oi_ratio,
|
||||||
|
COUNT(*) FILTER (WHERE s.vol_num > s.oi_num) as vol_exceeds_oi_count,
|
||||||
|
-- Strike clustering (institutional layering)
|
||||||
|
COUNT(DISTINCT s.strike_num) as unique_strikes,
|
||||||
|
CASE WHEN COUNT(DISTINCT s.strike_num) > 0 THEN
|
||||||
|
ROUND(COUNT(*)::numeric / COUNT(DISTINCT s.strike_num), 1)
|
||||||
|
ELSE 0 END as avg_trades_per_strike,
|
||||||
|
COALESCE(sc.max_trades_at_single_strike, 0) as max_trades_at_single_strike,
|
||||||
|
-- IV analysis
|
||||||
|
AVG(s.iv_num) FILTER (WHERE s.iv_num IS NOT NULL) as avg_iv
|
||||||
|
FROM scored s
|
||||||
|
LEFT JOIN strike_clusters sc ON sc.symbol_norm = s.symbol_norm
|
||||||
|
LEFT JOIN dte_buckets db ON db.symbol_norm = s.symbol_norm AND db.rn = 1
|
||||||
|
WHERE s.rocket_score >= $3 AND s.rocket_score <= $4
|
||||||
|
GROUP BY s.symbol_norm, sc.max_trades_at_single_strike, db.dte_bucket
|
||||||
|
HAVING COUNT(*) >= 1
|
||||||
|
)
|
||||||
|
SELECT * FROM symbol_agg
|
||||||
|
ORDER BY total_trades DESC, total_premium DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results = await rawQuery(query, [date, minPrem, minRocket, maxRocket]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
date,
|
||||||
|
symbols: results,
|
||||||
|
count: results.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Date analysis error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/backtest/symbol-performance
|
||||||
|
* Get price movement and profitability for a symbol after a specific date
|
||||||
|
*/
|
||||||
|
router.get('/symbol-performance', async (req, res) => {
|
||||||
|
const { symbol, entryDate, daysForward = 7 } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!symbol || !entryDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Symbol and entryDate parameters are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entry price
|
||||||
|
const entryPriceQuery = `
|
||||||
|
SELECT close, open, high, low, volume
|
||||||
|
FROM public.prices_daily
|
||||||
|
WHERE UPPER(symbol) = UPPER($1)
|
||||||
|
AND "Date" = $2::date
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const entryPriceData = await rawQuery(entryPriceQuery, [symbol, entryDate]);
|
||||||
|
|
||||||
|
if (!entryPriceData || entryPriceData.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: `No price data found for ${symbol} on ${entryDate}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPrice = parseFloat(entryPriceData[0].close);
|
||||||
|
|
||||||
|
if (isNaN(entryPrice) || !entryPrice) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: `Invalid entry price for ${symbol} on ${entryDate}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get price history for the next N days
|
||||||
|
const endDate = new Date(entryDate);
|
||||||
|
endDate.setDate(endDate.getDate() + parseInt(daysForward));
|
||||||
|
const endDateStr = endDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const priceHistoryQuery = `
|
||||||
|
SELECT "Date", close, open, high, low, volume
|
||||||
|
FROM public.prices_daily
|
||||||
|
WHERE UPPER(symbol) = UPPER($1)
|
||||||
|
AND "Date" > $2::date
|
||||||
|
AND "Date" <= $3::date
|
||||||
|
ORDER BY "Date" ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const priceHistory = await rawQuery(priceHistoryQuery, [symbol, entryDate, endDateStr]);
|
||||||
|
|
||||||
|
// Get signals for this symbol on entry date with all predictive factors
|
||||||
|
const signalsQuery = `
|
||||||
|
WITH base AS (
|
||||||
|
SELECT
|
||||||
|
ofm.ctid AS rid,
|
||||||
|
UPPER(TRIM(ofm."Symbol")) AS symbol_norm,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Spot"::text, '[\\s$,]', '', 'g'),'')::numeric AS spot_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Premium"::text, '[\\s$,]', '', 'g'),'')::numeric AS premium_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Volume"::text, '[\\s$,]', '', 'g'),'')::numeric AS vol_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."OI"::text, '[\\s$,]', '', 'g'),'')::numeric AS oi_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Strike"::text, '[\\s$,]', '', 'g'),'')::numeric AS strike_num,
|
||||||
|
NULLIF(REGEXP_REPLACE(ofm."Dte"::text, '[\\s$,]', '', 'g'),'')::numeric AS dte_num,
|
||||||
|
CASE WHEN UPPER(TRIM(ofm."CallPut")) IN ('C','CALL','CALLS','CE') THEN 'CALL'
|
||||||
|
WHEN UPPER(TRIM(ofm."CallPut")) IN ('P','PUT','PUTS','PE') THEN 'PUT'
|
||||||
|
END AS cp_norm,
|
||||||
|
CASE
|
||||||
|
WHEN UPPER(TRIM(ofm."Side")) IN ('A','AA','ASK','BUY','BOT','BTO','AT_ASK') THEN 'BUY'
|
||||||
|
WHEN UPPER(TRIM(ofm."Side")) IN ('B','BB','BID','SELL','SLD','STO','AT_BID') THEN 'SELL'
|
||||||
|
ELSE NULL
|
||||||
|
END AS side_norm,
|
||||||
|
CASE
|
||||||
|
WHEN ofm."CreatedDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN
|
||||||
|
to_timestamp(ofm."CreatedDate"::text || ' ' || COALESCE(btrim(ofm."CreatedTime"::text), '00:00:00'), 'YYYY-MM-DD HH24:MI:SS')
|
||||||
|
ELSE to_timestamp(ofm."CreatedDate"::text || ' ' || COALESCE(btrim(ofm."CreatedTime"::text), '00:00:00'), 'MM/DD/YYYY HH24:MI:SS')
|
||||||
|
END AS flow_ts_local
|
||||||
|
FROM public."OptionsFlow_monthly" ofm
|
||||||
|
WHERE UPPER(TRIM(ofm."Symbol")) = UPPER($1)
|
||||||
|
AND (
|
||||||
|
CASE
|
||||||
|
WHEN ofm."CreatedDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$'
|
||||||
|
THEN ofm."CreatedDate"::date
|
||||||
|
WHEN ofm."CreatedDate"::text ~ '^\\d{1,2}/\\d{1,2}/\\d{2,4}$'
|
||||||
|
THEN to_date(ofm."CreatedDate"::text, 'MM/DD/YYYY')
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
) = $2::date
|
||||||
|
AND ofm."Premium" IS NOT NULL
|
||||||
|
AND btrim(ofm."Premium"::text) <> ''
|
||||||
|
),
|
||||||
|
enriched AS (
|
||||||
|
SELECT
|
||||||
|
b.*,
|
||||||
|
-- Calculate moneyness
|
||||||
|
CASE
|
||||||
|
WHEN b.strike_num IS NOT NULL AND b.spot_num IS NOT NULL AND b.spot_num > 0 THEN
|
||||||
|
CASE
|
||||||
|
WHEN b.cp_norm = 'CALL' THEN ((b.strike_num - b.spot_num) / b.spot_num) * 100
|
||||||
|
WHEN b.cp_norm = 'PUT' THEN ((b.spot_num - b.strike_num) / b.spot_num) * 100
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
ELSE NULL
|
||||||
|
END AS mny_pct,
|
||||||
|
-- ITM/OTM classification
|
||||||
|
CASE
|
||||||
|
WHEN b.strike_num IS NOT NULL AND b.spot_num IS NOT NULL THEN
|
||||||
|
CASE
|
||||||
|
WHEN b.cp_norm = 'CALL' AND b.strike_num <= b.spot_num THEN 'ITM'
|
||||||
|
WHEN b.cp_norm = 'PUT' AND b.strike_num >= b.spot_num THEN 'ITM'
|
||||||
|
ELSE 'OTM'
|
||||||
|
END
|
||||||
|
ELSE NULL
|
||||||
|
END AS moneyness_class,
|
||||||
|
-- DTE bucket
|
||||||
|
CASE
|
||||||
|
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 3 THEN '0-3 DTE'
|
||||||
|
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 7 THEN '4-7 DTE'
|
||||||
|
WHEN b.dte_num IS NOT NULL AND b.dte_num <= 30 THEN '8-30 DTE'
|
||||||
|
WHEN b.dte_num IS NOT NULL THEN '30+ DTE'
|
||||||
|
ELSE NULL
|
||||||
|
END AS dte_bucket,
|
||||||
|
-- Volume/OI ratio
|
||||||
|
CASE
|
||||||
|
WHEN b.oi_num > 0 THEN b.vol_num / NULLIF(b.oi_num, 0)
|
||||||
|
ELSE NULL
|
||||||
|
END AS vol_oi_ratio,
|
||||||
|
-- Session bucket
|
||||||
|
CASE
|
||||||
|
WHEN EXTRACT(HOUR FROM b.flow_ts_local)::int BETWEEN 4 AND 9
|
||||||
|
AND NOT (EXTRACT(HOUR FROM b.flow_ts_local)::int = 9 AND EXTRACT(MINUTE FROM b.flow_ts_local)::int >= 30) THEN 'PRE'
|
||||||
|
WHEN (EXTRACT(HOUR FROM b.flow_ts_local)::int = 9 AND EXTRACT(MINUTE FROM b.flow_ts_local)::int >= 30)
|
||||||
|
OR (EXTRACT(HOUR FROM b.flow_ts_local)::int > 9 AND EXTRACT(HOUR FROM b.flow_ts_local)::int < 16) THEN 'RTH'
|
||||||
|
WHEN EXTRACT(HOUR FROM b.flow_ts_local)::int BETWEEN 16 AND 20 THEN 'POST'
|
||||||
|
ELSE 'OFF'
|
||||||
|
END AS session_bucket
|
||||||
|
FROM base b
|
||||||
|
),
|
||||||
|
flow AS (
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
CASE
|
||||||
|
WHEN (e.cp_norm='CALL' AND e.side_norm='BUY') OR (e.cp_norm='PUT' AND e.side_norm='SELL') THEN 'BULL'
|
||||||
|
WHEN (e.cp_norm='PUT' AND e.side_norm='BUY') OR (e.cp_norm='CALL' AND e.side_norm='SELL') THEN 'BEAR'
|
||||||
|
ELSE NULL
|
||||||
|
END AS direction
|
||||||
|
FROM enriched e
|
||||||
|
),
|
||||||
|
catalyst_check AS (
|
||||||
|
SELECT
|
||||||
|
f.*,
|
||||||
|
CASE WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public."AlertStream_monthly" a
|
||||||
|
WHERE (UPPER(TRIM(a."Ticker")) = f.symbol_norm
|
||||||
|
OR UPPER(TRIM(a."ticker")) = f.symbol_norm
|
||||||
|
OR UPPER(TRIM(a."Symbol")) = f.symbol_norm
|
||||||
|
OR UPPER(TRIM(a."symbol")) = f.symbol_norm)
|
||||||
|
AND (
|
||||||
|
CASE
|
||||||
|
WHEN a."Date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."Date"::date
|
||||||
|
WHEN a."date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."date"::date
|
||||||
|
WHEN a."AlertDate"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."AlertDate"::date
|
||||||
|
WHEN a."alert_date"::text ~ '^\\d{4}-\\d{2}-\\d{2}$' THEN a."alert_date"::date
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
) = $2::date
|
||||||
|
) THEN 1 ELSE 0 END AS catalyst_flag
|
||||||
|
FROM flow f
|
||||||
|
),
|
||||||
|
scored AS (
|
||||||
|
SELECT
|
||||||
|
c.*,
|
||||||
|
(CASE
|
||||||
|
WHEN c.premium_num >= 2000000 THEN 3.0
|
||||||
|
WHEN c.premium_num >= 800000 THEN 2.0
|
||||||
|
WHEN c.premium_num >= 200000 THEN 1.0
|
||||||
|
ELSE 0.0
|
||||||
|
END) AS rocket_score
|
||||||
|
FROM catalyst_check c
|
||||||
|
WHERE c.premium_num >= 80000
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
symbol_norm,
|
||||||
|
COUNT(*) as signal_count,
|
||||||
|
SUM(premium_num) as total_premium,
|
||||||
|
MAX(rocket_score) as max_rocket_score,
|
||||||
|
AVG(rocket_score) as avg_rocket_score,
|
||||||
|
STRING_AGG(DISTINCT direction, ', ') as directions,
|
||||||
|
STRING_AGG(DISTINCT cp_norm, ', ') as option_types,
|
||||||
|
-- Predictive factors
|
||||||
|
COUNT(*) FILTER (WHERE moneyness_class = 'ITM') as itm_count,
|
||||||
|
COUNT(*) FILTER (WHERE moneyness_class = 'OTM') as otm_count,
|
||||||
|
AVG(mny_pct) FILTER (WHERE mny_pct IS NOT NULL) as avg_moneyness_pct,
|
||||||
|
AVG(dte_num) FILTER (WHERE dte_num IS NOT NULL) as avg_dte,
|
||||||
|
MODE() WITHIN GROUP (ORDER BY dte_bucket) as most_common_dte_bucket,
|
||||||
|
COUNT(*) FILTER (WHERE session_bucket = 'RTH') as rth_count,
|
||||||
|
COUNT(*) FILTER (WHERE session_bucket = 'PRE') as pre_count,
|
||||||
|
COUNT(*) FILTER (WHERE session_bucket = 'POST') as post_count,
|
||||||
|
COUNT(*) FILTER (WHERE catalyst_flag = 1) as catalyst_count,
|
||||||
|
CASE WHEN COUNT(*) > 0 THEN
|
||||||
|
ROUND(100.0 * COUNT(*) FILTER (WHERE catalyst_flag = 1) / COUNT(*), 1)
|
||||||
|
ELSE 0 END as pct_with_catalyst,
|
||||||
|
AVG(vol_oi_ratio) FILTER (WHERE vol_oi_ratio IS NOT NULL) as avg_vol_oi_ratio,
|
||||||
|
COUNT(*) FILTER (WHERE vol_num > oi_num) as vol_exceeds_oi_count,
|
||||||
|
COUNT(DISTINCT strike_num) as unique_strikes
|
||||||
|
FROM scored
|
||||||
|
GROUP BY symbol_norm
|
||||||
|
`;
|
||||||
|
|
||||||
|
let signals = [];
|
||||||
|
try {
|
||||||
|
signals = await rawQuery(signalsQuery, [symbol, entryDate]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching signals:', error);
|
||||||
|
// Continue with empty signals array if query fails
|
||||||
|
signals = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate performance metrics
|
||||||
|
const priceData = priceHistory.map(p => {
|
||||||
|
const close = parseFloat(p.close) || 0;
|
||||||
|
const open = parseFloat(p.open) || 0;
|
||||||
|
const high = parseFloat(p.high) || 0;
|
||||||
|
const low = parseFloat(p.low) || 0;
|
||||||
|
const volume = parseFloat(p.volume || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: p.Date,
|
||||||
|
close,
|
||||||
|
open,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
volume,
|
||||||
|
change: close - entryPrice,
|
||||||
|
changePct: entryPrice > 0 ? ((close - entryPrice) / entryPrice) * 100 : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate profitability insights
|
||||||
|
const maxGain = priceData.length > 0
|
||||||
|
? Math.max(...priceData.map(p => p.changePct || 0))
|
||||||
|
: 0;
|
||||||
|
const maxLoss = priceData.length > 0
|
||||||
|
? Math.min(...priceData.map(p => p.changePct || 0))
|
||||||
|
: 0;
|
||||||
|
const finalGain = priceData.length > 0
|
||||||
|
? (priceData[priceData.length - 1].changePct || 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Determine if signals were profitable
|
||||||
|
const signalData = (signals && signals.length > 0) ? signals[0] : {};
|
||||||
|
const directions = signalData.directions || '';
|
||||||
|
const isBullish = directions.includes('BULL');
|
||||||
|
const isBearish = directions.includes('BEAR');
|
||||||
|
|
||||||
|
// Calculate separate profitability for each direction
|
||||||
|
const bullProfitable = finalGain > 0;
|
||||||
|
const bearProfitable = finalGain < 0;
|
||||||
|
|
||||||
|
let wouldBeProfitable = false;
|
||||||
|
let profitReason = '';
|
||||||
|
let detailedAnalysis = null;
|
||||||
|
|
||||||
|
if (isBullish && !isBearish) {
|
||||||
|
// Pure bullish signals
|
||||||
|
wouldBeProfitable = bullProfitable;
|
||||||
|
profitReason = wouldBeProfitable
|
||||||
|
? `✅ Price increased ${finalGain.toFixed(2)}% - Bullish signals would have been profitable`
|
||||||
|
: `❌ Price decreased ${Math.abs(finalGain).toFixed(2)}% - Bullish signals would have lost money`;
|
||||||
|
} else if (isBearish && !isBullish) {
|
||||||
|
// Pure bearish signals
|
||||||
|
wouldBeProfitable = bearProfitable;
|
||||||
|
profitReason = wouldBeProfitable
|
||||||
|
? `✅ Price decreased ${Math.abs(finalGain).toFixed(2)}% - Bearish signals would have been profitable`
|
||||||
|
: `❌ Price increased ${finalGain.toFixed(2)}% - Bearish signals would have lost money`;
|
||||||
|
} else {
|
||||||
|
// Mixed signals - analyze both directions
|
||||||
|
const priceMove = Math.abs(finalGain);
|
||||||
|
const significantMove = priceMove > 1;
|
||||||
|
|
||||||
|
// Determine overall profitability based on which direction had more premium
|
||||||
|
// If we can't determine, consider it mixed/unclear
|
||||||
|
wouldBeProfitable = significantMove && (bullProfitable || bearProfitable);
|
||||||
|
|
||||||
|
// Build detailed analysis for mixed signals
|
||||||
|
const bullResult = bullProfitable ? '✅ Profitable' : '❌ Loss';
|
||||||
|
const bearResult = bearProfitable ? '✅ Profitable' : '❌ Loss';
|
||||||
|
|
||||||
|
profitReason = `Mixed signals detected - Price moved ${finalGain >= 0 ? '+' : ''}${finalGain.toFixed(2)}%`;
|
||||||
|
|
||||||
|
detailedAnalysis = {
|
||||||
|
bullSignals: {
|
||||||
|
profitable: bullProfitable,
|
||||||
|
result: bullResult,
|
||||||
|
explanation: finalGain > 0
|
||||||
|
? `Price went UP ${finalGain.toFixed(2)}% - BULL signals would have been profitable`
|
||||||
|
: `Price went DOWN ${Math.abs(finalGain).toFixed(2)}% - BULL signals would have lost money`
|
||||||
|
},
|
||||||
|
bearSignals: {
|
||||||
|
profitable: bearProfitable,
|
||||||
|
result: bearResult,
|
||||||
|
explanation: finalGain < 0
|
||||||
|
? `Price went DOWN ${Math.abs(finalGain).toFixed(2)}% - BEAR signals would have been profitable`
|
||||||
|
: `Price went UP ${Math.abs(finalGain).toFixed(2)}% - BEAR signals would have lost money`
|
||||||
|
},
|
||||||
|
recommendation: finalGain < 0
|
||||||
|
? 'BEAR signals would have been profitable (price declined)'
|
||||||
|
: finalGain > 0
|
||||||
|
? 'BULL signals would have been profitable (price rose)'
|
||||||
|
: 'Price was flat - neither direction would have been profitable'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
symbol: symbol.toUpperCase(),
|
||||||
|
entryDate,
|
||||||
|
daysForward: parseInt(daysForward),
|
||||||
|
entryPrice,
|
||||||
|
signals: signalData,
|
||||||
|
priceHistory: priceData,
|
||||||
|
performance: {
|
||||||
|
maxGain,
|
||||||
|
maxLoss,
|
||||||
|
finalGain,
|
||||||
|
wouldBeProfitable,
|
||||||
|
profitReason,
|
||||||
|
detailedAnalysis,
|
||||||
|
bestDay: priceData.length > 0
|
||||||
|
? priceData.reduce((best, current) => {
|
||||||
|
const bestPct = Math.abs(best.changePct || 0);
|
||||||
|
const currentPct = Math.abs(current.changePct || 0);
|
||||||
|
return currentPct > bestPct ? current : best;
|
||||||
|
}, priceData[0])
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Symbol performance error:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
symbol,
|
||||||
|
entryDate,
|
||||||
|
daysForward,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
/**
|
||||||
|
* Market Analysis Routes
|
||||||
|
* Exposes deep stock analysis for top 50 universe
|
||||||
|
* All data from free sources (Yahoo Finance, Finviz, SEC EDGAR RSS)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import NodeCache from 'node-cache';
|
||||||
|
import { UNIVERSE, getSymbolMeta, classifyCapTier } from '../services/stockUniverseService.js';
|
||||||
|
import { fetchBasicQuote, fetchQuoteSummary, fetchTechnicals, fetchFullAnalysis } from '../services/fundamentalsService.js';
|
||||||
|
import { fetchInstitutionalHolders, fetchInsiderTransactions } from '../services/institutionalHoldersService.js';
|
||||||
|
import { fetchNewsForSymbol, fetchMarketNews } from '../services/newsService.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const universeCache = new NodeCache({ stdTTL: 60 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/market/universe
|
||||||
|
* Returns full top-50 list with price, change, cap tier
|
||||||
|
* Optional: ?cap=large|mid|small|etf ?sector=Technology
|
||||||
|
*/
|
||||||
|
router.get('/universe', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { cap, sector } = req.query;
|
||||||
|
const cacheKey = `universe_${cap || 'all'}_${sector || 'all'}`;
|
||||||
|
const cached = universeCache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, data: cached, cached: true, updatedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch quotes for all symbols concurrently (batched)
|
||||||
|
const CONCURRENCY = 8;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < UNIVERSE.length; i += CONCURRENCY) {
|
||||||
|
const batch = UNIVERSE.slice(i, i + CONCURRENCY);
|
||||||
|
const batchResults = await Promise.allSettled(
|
||||||
|
batch.map(async (entry) => {
|
||||||
|
const quote = await fetchBasicQuote(entry.symbol);
|
||||||
|
const capTier = classifyCapTier(quote?.market_cap ?? null, entry.isETF);
|
||||||
|
return {
|
||||||
|
symbol: entry.symbol,
|
||||||
|
name: entry.name,
|
||||||
|
sector: entry.sector,
|
||||||
|
isETF: entry.isETF,
|
||||||
|
capTier,
|
||||||
|
price: quote?.price ?? null,
|
||||||
|
change: quote?.change ?? null,
|
||||||
|
change_pct: quote?.change_pct ?? null,
|
||||||
|
market_cap: quote?.market_cap ?? null,
|
||||||
|
volume: quote?.volume ?? null,
|
||||||
|
high_52w: null, // populated by quoteSummary if needed
|
||||||
|
low_52w: null,
|
||||||
|
market_state: quote?.market_state ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
batchResults.forEach(r => {
|
||||||
|
if (r.status === 'fulfilled' && r.value) results.push(r.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay between batches
|
||||||
|
if (i + CONCURRENCY < UNIVERSE.length) {
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
let filtered = results;
|
||||||
|
if (cap) {
|
||||||
|
const capUpper = cap.toUpperCase();
|
||||||
|
filtered = filtered.filter(r => r.capTier === capUpper);
|
||||||
|
}
|
||||||
|
if (sector) {
|
||||||
|
filtered = filtered.filter(r => r.sector.toLowerCase() === sector.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: ETFs last, then by market cap desc
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
if (a.isETF !== b.isETF) return a.isETF ? 1 : -1;
|
||||||
|
return (b.market_cap || 0) - (a.market_cap || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
universeCache.set(cacheKey, filtered);
|
||||||
|
res.json({ success: true, data: filtered, count: filtered.length, updatedAt: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[market/universe]', err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/market/stock/:symbol
|
||||||
|
* Full deep analysis: quote + fundamentals + technicals
|
||||||
|
*/
|
||||||
|
router.get('/stock/:symbol', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const symbol = req.params.symbol.toUpperCase();
|
||||||
|
const meta = getSymbolMeta(symbol);
|
||||||
|
const analysis = await fetchFullAnalysis(symbol);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...analysis,
|
||||||
|
meta,
|
||||||
|
capTier: classifyCapTier(analysis.quote?.market_cap ?? null, meta?.isETF ?? false),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[market/stock/${req.params.symbol}]`, err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/market/stock/:symbol/news
|
||||||
|
* Financial news for a symbol from multiple free sources
|
||||||
|
*/
|
||||||
|
router.get('/stock/:symbol/news', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const symbol = req.params.symbol.toUpperCase();
|
||||||
|
const news = await fetchNewsForSymbol(symbol);
|
||||||
|
res.json({ success: true, data: news, count: news.length, symbol });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[market/news/${req.params.symbol}]`, err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/market/stock/:symbol/holders
|
||||||
|
* Institutional holders + insider transactions
|
||||||
|
*/
|
||||||
|
router.get('/stock/:symbol/holders', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const symbol = req.params.symbol.toUpperCase();
|
||||||
|
const [institutional, insiders] = await Promise.allSettled([
|
||||||
|
fetchInstitutionalHolders(symbol),
|
||||||
|
fetchInsiderTransactions(symbol),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
institutional: institutional.status === 'fulfilled' ? institutional.value : null,
|
||||||
|
insiders: insiders.status === 'fulfilled' ? insiders.value : null,
|
||||||
|
symbol,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[market/holders/${req.params.symbol}]`, err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/market/news
|
||||||
|
* Market-wide news (SPY, macro, broad market)
|
||||||
|
*/
|
||||||
|
router.get('/news', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const news = await fetchMarketNews();
|
||||||
|
res.json({ success: true, data: news, count: news.length });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/market/leaderboard
|
||||||
|
* Top movers and worst performers from the universe
|
||||||
|
*/
|
||||||
|
router.get('/leaderboard', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = 'leaderboard';
|
||||||
|
const cached = universeCache.get(cacheKey);
|
||||||
|
if (cached) return res.json({ success: true, data: cached });
|
||||||
|
|
||||||
|
const CONCURRENCY = 8;
|
||||||
|
const quotes = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < UNIVERSE.length; i += CONCURRENCY) {
|
||||||
|
const batch = UNIVERSE.slice(i, i + CONCURRENCY);
|
||||||
|
const batchResults = await Promise.allSettled(
|
||||||
|
batch.map(async (entry) => {
|
||||||
|
const q = await fetchBasicQuote(entry.symbol);
|
||||||
|
return q ? { symbol: entry.symbol, name: entry.name, isETF: entry.isETF, ...q } : null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
batchResults.forEach(r => {
|
||||||
|
if (r.status === 'fulfilled' && r.value) quotes.push(r.value);
|
||||||
|
});
|
||||||
|
if (i + CONCURRENCY < UNIVERSE.length) await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
const validQuotes = quotes.filter(q => q.change_pct != null);
|
||||||
|
const sorted = [...validQuotes].sort((a, b) => b.change_pct - a.change_pct);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
top_gainers: sorted.slice(0, 5),
|
||||||
|
top_losers: sorted.slice(-5).reverse(),
|
||||||
|
most_active: [...validQuotes].sort((a, b) => (b.volume || 0) - (a.volume || 0)).slice(0, 5),
|
||||||
|
};
|
||||||
|
|
||||||
|
universeCache.set(cacheKey, result);
|
||||||
|
res.json({ success: true, data: result, updatedAt: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import optionsFlowRouter from './routes/optionsFlow.js';
|
import optionsFlowRouter from './routes/optionsFlow.js';
|
||||||
import dailyAnalysisRouter from './routes/dailyAnalysis.js';
|
import dailyAnalysisRouter from './routes/dailyAnalysis.js';
|
||||||
import pricesRouter from './routes/prices.js';
|
import pricesRouter from './routes/prices.js';
|
||||||
|
|
@ -11,6 +13,7 @@ import tradePlansRouter from './routes/tradePlans.js';
|
||||||
import performanceRouter from './routes/performance.js';
|
import performanceRouter from './routes/performance.js';
|
||||||
import reversalsRouter from './routes/reversals.js';
|
import reversalsRouter from './routes/reversals.js';
|
||||||
import convergenceRouter from './routes/convergence.js';
|
import convergenceRouter from './routes/convergence.js';
|
||||||
|
import marketAnalysisRouter from './routes/marketAnalysis.js';
|
||||||
import backtestRouter from './routes/backtest.js';
|
import backtestRouter from './routes/backtest.js';
|
||||||
import healthRouter from './routes/health.js';
|
import healthRouter from './routes/health.js';
|
||||||
import { setupWebSocket } from './routes/websocket.js';
|
import { setupWebSocket } from './routes/websocket.js';
|
||||||
|
|
@ -54,9 +57,25 @@ app.use('/api/trade-plans', tradePlansRouter);
|
||||||
app.use('/api/performance', performanceRouter);
|
app.use('/api/performance', performanceRouter);
|
||||||
app.use('/api/reversals', reversalsRouter);
|
app.use('/api/reversals', reversalsRouter);
|
||||||
app.use('/api/convergence', convergenceRouter);
|
app.use('/api/convergence', convergenceRouter);
|
||||||
|
app.use('/api/market', marketAnalysisRouter);
|
||||||
app.use('/api/backtest', backtestRouter);
|
app.use('/api/backtest', backtestRouter);
|
||||||
app.use('/health', healthRouter);
|
app.use('/health', healthRouter);
|
||||||
|
|
||||||
|
// Serve static frontend files in production
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const frontendPath = path.join(__dirname, '../../public');
|
||||||
|
|
||||||
|
app.use(express.static(frontendPath));
|
||||||
|
|
||||||
|
// Fallback all other routes to React's index.html
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api/') || req.path.startsWith('/health')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
// Error handler (must be last)
|
// Error handler (must be last)
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ async function getFetch() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const BLACKBOX_API_URL = 'https://api.blackboxstocks.com/api/v2/download/downloadOptionsFlow';
|
const BLACKBOX_API_URL = 'https://api.blackboxstocks.com/api/v2/download/downloadOptionsFlow';
|
||||||
const BLACKBOX_API_TOKEN = process.env.BLACKBOX_API_TOKEN || 'eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoic3JlZGR5MDgxOUBnbWFpbC5jb20iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6Ijk3ZDA5NThjLTBhOTktNDA5OC05OWQwLWNkYTg3Njg5N2Q3ZiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL3NpZCI6ImViRTByZWZCUm10YTFqMmx1YzZwNHZKZGJqTEJKT1BrMVVJNnNXcmJFTWM9IiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlN1YnNjcmliZXIiLCJPcHRpb25zIiwiRXF1aXRpZXMiXSwiZXhwIjoxNzY3Mjg5MTI1fQ.JdsEjjrbsnRhvXgUNUu4K3ee_dava0ONfV_kyLK4Kmg';
|
const BLACKBOX_API_TOKEN = process.env.BLACKBOX_API_TOKEN || 'eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoic3JlZGR5MDgxOUBnbWFpbC5jb20iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6Ijk3ZDA5NThjLTBhOTktNDA5OC05OWQwLWNkYTg3Njg5N2Q3ZiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL3NpZCI6ImViRTByZWZCUm10YTFqMmx1YzZwNHZKZGJqTEJKT1BrMVVJNnNXcmJFTWM9IiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlN1YnNjcmliZXIiLCJPcHRpb25zIiwiRXF1aXRpZXMiXSwiZXhwIjoxNzY3NDUyMzgwfQ.48oftWtLRXsteaErfIwUhygYoeAmdl_hbPWZ7590wqQ';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build default filters object for the downloadOptionsFlow endpoint
|
* Build default filters object for the downloadOptionsFlow endpoint
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,291 @@
|
||||||
|
/**
|
||||||
|
* Fundamentals Service
|
||||||
|
* Fetches deep trader metrics from Yahoo Finance using authenticated crumb requests
|
||||||
|
* Calculates RSI-14, MACD (12,26,9), SMAs from OHLCV data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import NodeCache from 'node-cache';
|
||||||
|
import { classifyCapTier } from './stockUniverseService.js';
|
||||||
|
import { yfFetchAuth } from './yahooCrumb.js';
|
||||||
|
|
||||||
|
const fundamentalsCache = new NodeCache({ stdTTL: 60 });
|
||||||
|
const technicalCache = new NodeCache({ stdTTL: 300 });
|
||||||
|
const quoteSummaryCache = new NodeCache({ stdTTL: 120 });
|
||||||
|
|
||||||
|
const YF_BASE = 'https://query1.finance.yahoo.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch basic quote (price, volume, change, market cap)
|
||||||
|
*/
|
||||||
|
export async function fetchBasicQuote(symbol) {
|
||||||
|
const cacheKey = `quote_${symbol}`;
|
||||||
|
const cached = fundamentalsCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=5d`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await yfFetchAuth(url);
|
||||||
|
const result = data?.chart?.result?.[0];
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const meta = result.meta;
|
||||||
|
const quotes = result.indicators?.quote?.[0] || {};
|
||||||
|
const timestamps = result.timestamp || [];
|
||||||
|
|
||||||
|
const currentPrice = meta.regularMarketPrice ?? meta.previousClose ?? null;
|
||||||
|
const prevClose = meta.previousClose ?? null;
|
||||||
|
const change = currentPrice && prevClose ? currentPrice - prevClose : null;
|
||||||
|
const changePct = change && prevClose ? (change / prevClose) * 100 : null;
|
||||||
|
|
||||||
|
const history = [];
|
||||||
|
const startIdx = Math.max(0, timestamps.length - 5);
|
||||||
|
for (let i = startIdx; i < timestamps.length; i++) {
|
||||||
|
const v = quotes.volume?.[i];
|
||||||
|
const c = quotes.close?.[i];
|
||||||
|
if (c != null) {
|
||||||
|
history.push({
|
||||||
|
date: new Date(timestamps[i] * 1000).toISOString().split('T')[0],
|
||||||
|
open: quotes.open?.[i] ?? null,
|
||||||
|
high: quotes.high?.[i] ?? null,
|
||||||
|
low: quotes.low?.[i] ?? null,
|
||||||
|
close: c,
|
||||||
|
volume: v ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
history.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
|
||||||
|
const q = {
|
||||||
|
symbol: symbol.toUpperCase(),
|
||||||
|
price: currentPrice,
|
||||||
|
prev_close: prevClose,
|
||||||
|
change,
|
||||||
|
change_pct: changePct ? Math.round(changePct * 100) / 100 : null,
|
||||||
|
open: meta.regularMarketOpen ?? null,
|
||||||
|
high: meta.regularMarketDayHigh ?? null,
|
||||||
|
low: meta.regularMarketDayLow ?? null,
|
||||||
|
volume: meta.regularMarketVolume ?? null,
|
||||||
|
market_cap: meta.marketCap ?? null,
|
||||||
|
currency: meta.currency ?? 'USD',
|
||||||
|
exchange: meta.exchangeName ?? null,
|
||||||
|
market_state: meta.marketState ?? null,
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
|
||||||
|
fundamentalsCache.set(cacheKey, q);
|
||||||
|
return q;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[fundamentalsService] quote failed for ${symbol}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full fundamentals via quoteSummary v10
|
||||||
|
*/
|
||||||
|
export async function fetchQuoteSummary(symbol) {
|
||||||
|
const cacheKey = `qs_${symbol}`;
|
||||||
|
const cached = quoteSummaryCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const modules = [
|
||||||
|
'summaryDetail',
|
||||||
|
'defaultKeyStatistics',
|
||||||
|
'financialData',
|
||||||
|
'recommendationTrend',
|
||||||
|
'earningsTrend'
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const url = `${YF_BASE}/v10/finance/quoteSummary/${symbol}?modules=${modules}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await yfFetchAuth(url);
|
||||||
|
const result = data?.quoteSummary?.result?.[0];
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const sd = result.summaryDetail || {};
|
||||||
|
const dks = result.defaultKeyStatistics || {};
|
||||||
|
const fd = result.financialData || {};
|
||||||
|
const rt = result.recommendationTrend?.trend?.[0] || {};
|
||||||
|
|
||||||
|
const parsed = {
|
||||||
|
pe_ttm: sd.trailingPE?.raw ?? null,
|
||||||
|
pe_forward: sd.forwardPE?.raw ?? null,
|
||||||
|
price_to_book: dks.priceToBook?.raw ?? null,
|
||||||
|
price_to_sales: dks.priceToSalesTrailing12Months?.raw ?? null,
|
||||||
|
ev_ebitda: dks.enterpriseToEbitda?.raw ?? null,
|
||||||
|
eps_ttm: dks.trailingEps?.raw ?? null,
|
||||||
|
eps_forward: dks.forwardEps?.raw ?? null,
|
||||||
|
earnings_growth: fd.earningsGrowth?.raw ?? null,
|
||||||
|
revenue_growth: fd.revenueGrowth?.raw ?? null,
|
||||||
|
gross_margins: fd.grossMargins?.raw ?? null,
|
||||||
|
ebitda_margins: fd.ebitdaMargins?.raw ?? null,
|
||||||
|
operating_margins: fd.operatingMargins?.raw ?? null,
|
||||||
|
profit_margins: dks.profitMargins?.raw ?? null,
|
||||||
|
debt_to_equity: fd.debtToEquity?.raw ?? null,
|
||||||
|
current_ratio: fd.currentRatio?.raw ?? null,
|
||||||
|
quick_ratio: fd.quickRatio?.raw ?? null,
|
||||||
|
return_on_equity: fd.returnOnEquity?.raw ?? null,
|
||||||
|
return_on_assets: fd.returnOnAssets?.raw ?? null,
|
||||||
|
free_cash_flow: fd.freeCashflow?.raw ?? null,
|
||||||
|
total_revenue: fd.totalRevenue?.raw ?? null,
|
||||||
|
total_debt: fd.totalDebt?.raw ?? null,
|
||||||
|
beta: sd.beta?.raw ?? null,
|
||||||
|
market_cap: sd.marketCap?.raw ?? null,
|
||||||
|
shares_outstanding: dks.sharesOutstanding?.raw ?? null,
|
||||||
|
float_shares: dks.floatShares?.raw ?? null,
|
||||||
|
short_pct_float: dks.shortPercentOfFloat?.raw ?? null,
|
||||||
|
shares_short: dks.sharesShort?.raw ?? null,
|
||||||
|
held_pct_institutions: dks.heldPercentInstitutions?.raw ?? null,
|
||||||
|
held_pct_insiders: dks.heldPercentInsiders?.raw ?? null,
|
||||||
|
book_value: dks.bookValue?.raw ?? null,
|
||||||
|
week52_high: sd.fiftyTwoWeekHigh?.raw ?? null,
|
||||||
|
week52_low: sd.fiftyTwoWeekLow?.raw ?? null,
|
||||||
|
week52_change: dks.fiftyTwoWeekChange?.raw ?? null,
|
||||||
|
avg_volume_10d: sd.averageVolume10days?.raw ?? null,
|
||||||
|
avg_volume_3m: sd.averageVolume?.raw ?? null,
|
||||||
|
dividend_yield: sd.dividendYield?.raw ?? null,
|
||||||
|
payout_ratio: sd.payoutRatio?.raw ?? null,
|
||||||
|
target_high: fd.targetHighPrice?.raw ?? null,
|
||||||
|
target_low: fd.targetLowPrice?.raw ?? null,
|
||||||
|
target_mean: fd.targetMeanPrice?.raw ?? null,
|
||||||
|
target_median: fd.targetMedianPrice?.raw ?? null,
|
||||||
|
recommendation: fd.recommendationKey ?? null,
|
||||||
|
analyst_count: fd.numberOfAnalystOpinions?.raw ?? null,
|
||||||
|
rec_strong_buy: rt.strongBuy ?? 0,
|
||||||
|
rec_buy: rt.buy ?? 0,
|
||||||
|
rec_hold: rt.hold ?? 0,
|
||||||
|
rec_sell: rt.sell ?? 0,
|
||||||
|
rec_strong_sell: rt.strongSell ?? 0,
|
||||||
|
capTier: classifyCapTier(sd.marketCap?.raw ?? null),
|
||||||
|
};
|
||||||
|
|
||||||
|
quoteSummaryCache.set(cacheKey, parsed);
|
||||||
|
return parsed;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[fundamentalsService] quoteSummary failed for ${symbol}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch OHLCV history and calculate technical indicators
|
||||||
|
*/
|
||||||
|
export async function fetchTechnicals(symbol) {
|
||||||
|
const cacheKey = `tech_${symbol}`;
|
||||||
|
const cached = technicalCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=1y`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await yfFetchAuth(url);
|
||||||
|
const result = data?.chart?.result?.[0];
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const closes = result.indicators?.quote?.[0]?.close?.filter(v => v != null) || [];
|
||||||
|
if (closes.length < 26) return null;
|
||||||
|
|
||||||
|
const rsi = calculateRSI(closes, 14);
|
||||||
|
const macd = calculateMACD(closes, 12, 26, 9);
|
||||||
|
const sma50 = closes.length >= 50 ? avg(closes.slice(-50)) : null;
|
||||||
|
const sma200 = closes.length >= 200 ? avg(closes.slice(-200)) : null;
|
||||||
|
const currentPrice = closes[closes.length - 1];
|
||||||
|
|
||||||
|
const result_data = {
|
||||||
|
rsi_14: rsi !== null ? Math.round(rsi * 100) / 100 : null,
|
||||||
|
macd_line: macd?.line ?? null,
|
||||||
|
macd_signal: macd?.signal ?? null,
|
||||||
|
macd_histogram: macd?.histogram ?? null,
|
||||||
|
sma_50: sma50 ? Math.round(sma50 * 100) / 100 : null,
|
||||||
|
sma_200: sma200 ? Math.round(sma200 * 100) / 100 : null,
|
||||||
|
above_sma50: sma50 ? currentPrice > sma50 : null,
|
||||||
|
above_sma200: sma200 ? currentPrice > sma200 : null,
|
||||||
|
pct_from_sma50: sma50 ? ((currentPrice - sma50) / sma50 * 100) : null,
|
||||||
|
pct_from_sma200: sma200 ? ((currentPrice - sma200) / sma200 * 100) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
technicalCache.set(cacheKey, result_data);
|
||||||
|
return result_data;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[fundamentalsService] technicals failed for ${symbol}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full deep analysis for a single symbol
|
||||||
|
*/
|
||||||
|
export async function fetchFullAnalysis(symbol) {
|
||||||
|
const [quote, fundamentals, technicals] = await Promise.allSettled([
|
||||||
|
fetchBasicQuote(symbol),
|
||||||
|
fetchQuoteSummary(symbol),
|
||||||
|
fetchTechnicals(symbol),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol: symbol.toUpperCase(),
|
||||||
|
quote: quote.status === 'fulfilled' ? quote.value : null,
|
||||||
|
fundamentals: fundamentals.status === 'fulfilled' ? fundamentals.value : null,
|
||||||
|
technicals: technicals.status === 'fulfilled' ? technicals.value : null,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Technical Indicator Calculations ───────────────────────────────────────
|
||||||
|
|
||||||
|
function avg(arr) {
|
||||||
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRSI(closes, period = 14) {
|
||||||
|
if (closes.length < period + 1) return null;
|
||||||
|
const recent = closes.slice(-period - 1);
|
||||||
|
let gains = 0, losses = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i <= period; i++) {
|
||||||
|
const diff = recent[i] - recent[i - 1];
|
||||||
|
if (diff >= 0) gains += diff;
|
||||||
|
else losses += Math.abs(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgGain = gains / period;
|
||||||
|
const avgLoss = losses / period;
|
||||||
|
|
||||||
|
if (avgLoss === 0) return 100;
|
||||||
|
const rs = avgGain / avgLoss;
|
||||||
|
return 100 - (100 / (1 + rs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ema(data, period) {
|
||||||
|
if (data.length < period) return [];
|
||||||
|
const k = 2 / (period + 1);
|
||||||
|
const result = [];
|
||||||
|
let emaVal = avg(data.slice(0, period));
|
||||||
|
result.push(emaVal);
|
||||||
|
for (let i = period; i < data.length; i++) {
|
||||||
|
emaVal = data[i] * k + emaVal * (1 - k);
|
||||||
|
result.push(emaVal);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateMACD(closes, fast = 12, slow = 26, signal = 9) {
|
||||||
|
if (closes.length < slow + signal) return null;
|
||||||
|
const emaFast = ema(closes, fast);
|
||||||
|
const emaSlow = ema(closes, slow);
|
||||||
|
|
||||||
|
const diff = emaSlow.length - emaFast.length;
|
||||||
|
const macdLine = emaFast.slice(diff).map((v, i) => v - emaSlow[i]);
|
||||||
|
const signalLine = ema(macdLine, signal);
|
||||||
|
|
||||||
|
const lastMacd = macdLine[macdLine.length - 1];
|
||||||
|
const lastSignal = signalLine[signalLine.length - 1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
line: Math.round(lastMacd * 10000) / 10000,
|
||||||
|
signal: Math.round(lastSignal * 10000) / 10000,
|
||||||
|
histogram: Math.round((lastMacd - lastSignal) * 10000) / 10000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* Institutional Holders Service
|
||||||
|
* Fetches top institutional holders, insider transactions using native fetch and crumb auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import NodeCache from 'node-cache';
|
||||||
|
import { yfFetchAuth } from './yahooCrumb.js';
|
||||||
|
|
||||||
|
const holdersCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
const insidersCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
|
const YF_BASE = 'https://query1.finance.yahoo.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch top institutional holders for a symbol
|
||||||
|
*/
|
||||||
|
export async function fetchInstitutionalHolders(symbol) {
|
||||||
|
const cacheKey = `inst_${symbol}`;
|
||||||
|
const cached = holdersCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${YF_BASE}/v10/finance/quoteSummary/${symbol}?modules=institutionOwnership,majorHoldersBreakdown`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await yfFetchAuth(url);
|
||||||
|
const result = data?.quoteSummary?.result?.[0];
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const io = result.institutionOwnership || {};
|
||||||
|
const mhb = result.majorHoldersBreakdown || {};
|
||||||
|
|
||||||
|
const holders = (io.ownershipList || []).map(h => ({
|
||||||
|
name: h.organization ?? 'Unknown',
|
||||||
|
pct_held: h.pctHeld?.raw ?? null,
|
||||||
|
shares: h.position?.raw ?? null,
|
||||||
|
value: h.value?.raw ?? null,
|
||||||
|
date_reported: h.reportDate?.fmt ?? null,
|
||||||
|
shares_change: h.change?.raw ?? null,
|
||||||
|
pct_change: h.pctChange?.raw ?? null,
|
||||||
|
direction: (h.change?.raw ?? 0) > 0 ? 'INCREASED' : (h.change?.raw ?? 0) < 0 ? 'DECREASED' : 'UNCHANGED',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
pct_held_institutions: mhb.institutionsPercentHeld?.raw ?? null,
|
||||||
|
pct_held_insiders: mhb.insidersPercentHeld?.raw ?? null,
|
||||||
|
pct_float_institutions: mhb.institutionsFloatPercentHeld?.raw ?? null,
|
||||||
|
institution_count: mhb.institutionsCount?.raw ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedData = { holders, summary };
|
||||||
|
holdersCache.set(cacheKey, parsedData);
|
||||||
|
return parsedData;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[institutionalHolders] failed for ${symbol}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent insider transactions
|
||||||
|
*/
|
||||||
|
export async function fetchInsiderTransactions(symbol) {
|
||||||
|
const cacheKey = `insider_${symbol}`;
|
||||||
|
const cached = insidersCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${YF_BASE}/v10/finance/quoteSummary/${symbol}?modules=insiderTransactions,insiderHolders`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await yfFetchAuth(url);
|
||||||
|
const result = data?.quoteSummary?.result?.[0];
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const it = result.insiderTransactions || {};
|
||||||
|
const ih = result.insiderHolders || {};
|
||||||
|
|
||||||
|
const transactions = (it.transactions || []).slice(0, 20).map(t => {
|
||||||
|
const typeText = t.transactionText ?? '';
|
||||||
|
const direction =
|
||||||
|
/sale|sell/i.test(typeText) ? 'SELL' :
|
||||||
|
/purchase|buy|acquisition/i.test(typeText) ? 'BUY' : 'OTHER';
|
||||||
|
|
||||||
|
return {
|
||||||
|
insider_name: t.filerName ?? 'Unknown',
|
||||||
|
title: t.filerRelation ?? null,
|
||||||
|
transaction_type: typeText,
|
||||||
|
shares: t.shares?.raw ?? null,
|
||||||
|
value: t.value?.raw ?? null,
|
||||||
|
start_date: t.startDate?.fmt ?? null,
|
||||||
|
ownership_type: t.ownership ?? null,
|
||||||
|
direction,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const ninetyDaysAgo = now - 90 * 24 * 60 * 60 * 1000;
|
||||||
|
const recent = transactions.filter(t => t.start_date && new Date(t.start_date).getTime() > ninetyDaysAgo);
|
||||||
|
const buyCount = recent.filter(t => t.direction === 'BUY').length;
|
||||||
|
const sellCount = recent.filter(t => t.direction === 'SELL').length;
|
||||||
|
|
||||||
|
const parsedData = {
|
||||||
|
transactions,
|
||||||
|
insider_summary: {
|
||||||
|
buys_90d: buyCount,
|
||||||
|
sells_90d: sellCount,
|
||||||
|
net_sentiment: buyCount > sellCount ? 'BULLISH' : sellCount > buyCount ? 'BEARISH' : 'NEUTRAL',
|
||||||
|
},
|
||||||
|
holders: (ih.holders || []).slice(0, 10).map(h => ({
|
||||||
|
name: h.name ?? 'Unknown',
|
||||||
|
title: h.relation ?? null,
|
||||||
|
shares: h.positionDirect?.raw ?? null,
|
||||||
|
pct_total: h.pctHeld?.raw ?? null,
|
||||||
|
latest_transaction: h.transactionDescription ?? null,
|
||||||
|
latest_date: h.latestTransDate?.fmt ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
insidersCache.set(cacheKey, parsedData);
|
||||||
|
return parsedData;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[insiderTransactions] failed for ${symbol}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
/**
|
||||||
|
* News Service
|
||||||
|
* Aggregates financial news from free sources:
|
||||||
|
* - Yahoo Finance RSS per ticker
|
||||||
|
* - Google News RSS (filtered to financial keywords)
|
||||||
|
* - Finviz news table scraper
|
||||||
|
* - Seeking Alpha RSS
|
||||||
|
*/
|
||||||
|
|
||||||
|
import NodeCache from 'node-cache';
|
||||||
|
import { parseStringPromise } from 'xml2js';
|
||||||
|
|
||||||
|
const newsCache = new NodeCache({ stdTTL: 300 }); // 5min cache
|
||||||
|
|
||||||
|
const FINANCE_KEYWORDS = [
|
||||||
|
'earnings', 'revenue', 'profit', 'loss', 'EPS', 'guidance', 'forecast',
|
||||||
|
'analyst', 'upgrade', 'downgrade', 'buy', 'sell', 'hold', 'target',
|
||||||
|
'merger', 'acquisition', 'buyback', 'dividend', 'split', 'IPO', 'offering',
|
||||||
|
'FDA', 'approval', 'patent', 'lawsuit', 'SEC', 'investigation',
|
||||||
|
'Fed', 'rate', 'inflation', 'GDP', 'market', 'stock', 'shares',
|
||||||
|
'quarter', 'annual', 'fiscal', 'outlook', 'beat', 'miss', 'exceed',
|
||||||
|
'short', 'hedge', 'fund', 'institutional', 'investment', 'investor',
|
||||||
|
'growth', 'decline', 'surge', 'drop', 'rally', 'correction', 'pullback'
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchRSS(url, timeout = 8000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: DEFAULT_HEADERS, signal: controller.signal });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const text = await res.text();
|
||||||
|
return parseStringPromise(text, { explicitArray: false, ignoreAttrs: false });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract items from parsed RSS feed
|
||||||
|
*/
|
||||||
|
function extractRSSItems(parsed, source) {
|
||||||
|
try {
|
||||||
|
const items = parsed?.rss?.channel?.item || parsed?.feed?.entry || [];
|
||||||
|
const list = Array.isArray(items) ? items : [items];
|
||||||
|
return list.map(item => ({
|
||||||
|
title: item.title?._ || item.title || '',
|
||||||
|
url: item.link?.href || item.link || item.guid?._ || item.guid || '',
|
||||||
|
publishedAt: item.pubDate || item.published || item.updated || '',
|
||||||
|
source,
|
||||||
|
snippet: item.description?._ || item.description || item.summary?._ || item.summary || '',
|
||||||
|
})).filter(i => i.title);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to only financial/business relevant articles
|
||||||
|
*/
|
||||||
|
function isFinancialNews(item) {
|
||||||
|
const text = `${item.title} ${item.snippet}`.toLowerCase();
|
||||||
|
return FINANCE_KEYWORDS.some(kw => text.includes(kw.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrape news from Finviz (no API key needed)
|
||||||
|
*/
|
||||||
|
async function fetchFinvizNews(symbol, timeout = 8000) {
|
||||||
|
const url = `https://finviz.com/quote.ashx?t=${symbol}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_HEADERS,
|
||||||
|
'Accept': 'text/html,application/xhtml+xml',
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
// Extract news table rows using regex (no DOM parser needed)
|
||||||
|
// Finviz news rows look like: <tr class="cursor-pointer"><td ...><a href="..." class="tab-link">TITLE</a>...DATE</td>
|
||||||
|
const newsRowRegex = /<a[^>]+class="[^"]*tab-link[^"]*"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi;
|
||||||
|
const dateRegex = /<td[^>]*class="[^"]*news-date[^"]*"[^>]*>([^<]+)<\/td>/gi;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
const dates = [];
|
||||||
|
while ((match = dateRegex.exec(html)) !== null) {
|
||||||
|
dates.push(match[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let linkIdx = 0;
|
||||||
|
while ((match = newsRowRegex.exec(html)) !== null) {
|
||||||
|
const href = match[1];
|
||||||
|
const title = match[2].trim();
|
||||||
|
if (title && href && !href.startsWith('/') && title.length > 10) {
|
||||||
|
items.push({
|
||||||
|
title,
|
||||||
|
url: href,
|
||||||
|
publishedAt: dates[linkIdx] || '',
|
||||||
|
source: 'Finviz',
|
||||||
|
snippet: '',
|
||||||
|
});
|
||||||
|
linkIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, 15);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign basic sentiment based on keywords
|
||||||
|
*/
|
||||||
|
function assignSentiment(title) {
|
||||||
|
const text = title.toLowerCase();
|
||||||
|
const bullish = ['beat', 'surge', 'rally', 'upgrade', 'buy', 'strong', 'growth', 'profit', 'record', 'high', 'exceed', 'outperform', 'dividend', 'buyback'];
|
||||||
|
const bearish = ['miss', 'drop', 'fall', 'downgrade', 'sell', 'weak', 'loss', 'cut', 'warning', 'investigation', 'lawsuit', 'layoff', 'short', 'concern'];
|
||||||
|
|
||||||
|
const bullScore = bullish.filter(w => text.includes(w)).length;
|
||||||
|
const bearScore = bearish.filter(w => text.includes(w)).length;
|
||||||
|
|
||||||
|
if (bullScore > bearScore) return 'BULLISH';
|
||||||
|
if (bearScore > bullScore) return 'BEARISH';
|
||||||
|
return 'NEUTRAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all news for a symbol from multiple free sources
|
||||||
|
*/
|
||||||
|
export async function fetchNewsForSymbol(symbol) {
|
||||||
|
const cacheKey = `news_${symbol}`;
|
||||||
|
const cached = newsCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const sources = [
|
||||||
|
{
|
||||||
|
name: 'Yahoo Finance',
|
||||||
|
fetch: () => fetchRSS(`https://finance.yahoo.com/rss/headline?s=${symbol}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Google News',
|
||||||
|
fetch: () => fetchRSS(`https://news.google.com/rss/search?q=${symbol}+stock+market&hl=en-US&gl=US&ceid=US:en`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Seeking Alpha',
|
||||||
|
fetch: () => fetchRSS(`https://seekingalpha.com/api/sa/combined/${symbol}.xml`),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
...sources.map(s => s.fetch()),
|
||||||
|
fetchFinvizNews(symbol),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let allItems = [];
|
||||||
|
|
||||||
|
// Process RSS sources
|
||||||
|
results.slice(0, sources.length).forEach((res, i) => {
|
||||||
|
if (res.status === 'fulfilled' && res.value) {
|
||||||
|
const items = extractRSSItems(res.value, sources[i].name);
|
||||||
|
allItems.push(...items);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process Finviz
|
||||||
|
const finvizResult = results[sources.length];
|
||||||
|
if (finvizResult.status === 'fulfilled') {
|
||||||
|
allItems.push(...(finvizResult.value || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to financial news only (except Yahoo Finance — already financial)
|
||||||
|
allItems = allItems.filter(item => {
|
||||||
|
if (item.source === 'Yahoo Finance' || item.source === 'Finviz') return true;
|
||||||
|
return isFinancialNews(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate by title similarity
|
||||||
|
const seen = new Set();
|
||||||
|
allItems = allItems.filter(item => {
|
||||||
|
const key = item.title.slice(0, 60).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add sentiment and sort
|
||||||
|
allItems = allItems.map(item => ({
|
||||||
|
...item,
|
||||||
|
sentiment: assignSentiment(item.title),
|
||||||
|
symbol: symbol.toUpperCase(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by date (most recent first) — handle mixed date formats
|
||||||
|
allItems.sort((a, b) => {
|
||||||
|
try {
|
||||||
|
return new Date(b.publishedAt) - new Date(a.publishedAt);
|
||||||
|
} catch { return 0; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalItems = allItems.slice(0, 25);
|
||||||
|
newsCache.set(cacheKey, finalItems);
|
||||||
|
return finalItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch market-wide news (SPY, QQQ, broader market)
|
||||||
|
*/
|
||||||
|
export async function fetchMarketNews() {
|
||||||
|
const cacheKey = 'market_news';
|
||||||
|
const cached = newsCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [spyNews, marketRSS] = await Promise.allSettled([
|
||||||
|
fetchNewsForSymbol('SPY'),
|
||||||
|
fetchRSS('https://feeds.finance.yahoo.com/rss/2.0/headline?s=SPY,QQQ,^GSPC®ion=US&lang=en-US'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(spyNews.status === 'fulfilled' ? spyNews.value : []),
|
||||||
|
].slice(0, 20);
|
||||||
|
|
||||||
|
newsCache.set(cacheKey, items);
|
||||||
|
return items;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Stock Universe Service
|
||||||
|
* Manages the top 50 stocks + ETFs universe with cap tier classification
|
||||||
|
*/
|
||||||
|
|
||||||
|
import NodeCache from 'node-cache';
|
||||||
|
|
||||||
|
const cache = new NodeCache({ stdTTL: 60 }); // 60 second cache
|
||||||
|
|
||||||
|
// Cap tier thresholds
|
||||||
|
const CAP_TIERS = {
|
||||||
|
LARGE: 10_000_000_000, // > $10B
|
||||||
|
MID: 2_000_000_000, // $2B - $10B
|
||||||
|
// Below $2B = SMALL
|
||||||
|
};
|
||||||
|
|
||||||
|
// Master universe of top 50 stocks + ETFs
|
||||||
|
export const UNIVERSE = [
|
||||||
|
// Mega-cap Tech
|
||||||
|
{ symbol: 'AAPL', name: 'Apple Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'MSFT', name: 'Microsoft Corp.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'NVDA', name: 'NVIDIA Corp.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'AMZN', name: 'Amazon.com Inc.', sector: 'Consumer Discret.', isETF: false },
|
||||||
|
{ symbol: 'GOOGL', name: 'Alphabet Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'META', name: 'Meta Platforms', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'TSLA', name: 'Tesla Inc.', sector: 'Consumer Discret.', isETF: false },
|
||||||
|
{ symbol: 'AVGO', name: 'Broadcom Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'ORCL', name: 'Oracle Corp.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'AMD', name: 'Advanced Micro Devices', sector: 'Technology', isETF: false },
|
||||||
|
// Financials
|
||||||
|
{ symbol: 'JPM', name: 'JPMorgan Chase', sector: 'Financials', isETF: false },
|
||||||
|
{ symbol: 'BAC', name: 'Bank of America', sector: 'Financials', isETF: false },
|
||||||
|
{ symbol: 'WFC', name: 'Wells Fargo', sector: 'Financials', isETF: false },
|
||||||
|
{ symbol: 'GS', name: 'Goldman Sachs', sector: 'Financials', isETF: false },
|
||||||
|
{ symbol: 'V', name: 'Visa Inc.', sector: 'Financials', isETF: false },
|
||||||
|
{ symbol: 'MA', name: 'Mastercard Inc.', sector: 'Financials', isETF: false },
|
||||||
|
// Healthcare
|
||||||
|
{ symbol: 'UNH', name: 'UnitedHealth Group', sector: 'Healthcare', isETF: false },
|
||||||
|
{ symbol: 'LLY', name: 'Eli Lilly & Co.', sector: 'Healthcare', isETF: false },
|
||||||
|
{ symbol: 'JNJ', name: 'Johnson & Johnson', sector: 'Healthcare', isETF: false },
|
||||||
|
{ symbol: 'ABBV', name: 'AbbVie Inc.', sector: 'Healthcare', isETF: false },
|
||||||
|
{ symbol: 'PFE', name: 'Pfizer Inc.', sector: 'Healthcare', isETF: false },
|
||||||
|
// Energy
|
||||||
|
{ symbol: 'XOM', name: 'Exxon Mobil', sector: 'Energy', isETF: false },
|
||||||
|
{ symbol: 'CVX', name: 'Chevron Corp.', sector: 'Energy', isETF: false },
|
||||||
|
// Industrials
|
||||||
|
{ symbol: 'CAT', name: 'Caterpillar Inc.', sector: 'Industrials', isETF: false },
|
||||||
|
{ symbol: 'HON', name: 'Honeywell Intl.', sector: 'Industrials', isETF: false },
|
||||||
|
// Consumer
|
||||||
|
{ symbol: 'WMT', name: 'Walmart Inc.', sector: 'Consumer Staples', isETF: false },
|
||||||
|
{ symbol: 'COST', name: 'Costco Wholesale', sector: 'Consumer Staples', isETF: false },
|
||||||
|
// Semiconductors
|
||||||
|
{ symbol: 'QCOM', name: 'Qualcomm Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'MU', name: 'Micron Technology', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'INTC', name: 'Intel Corp.', sector: 'Technology', isETF: false },
|
||||||
|
// Growth / Momentum
|
||||||
|
{ symbol: 'PLTR', name: 'Palantir Technologies', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'CRWD', name: 'CrowdStrike Holdings', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'SNOW', name: 'Snowflake Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'COIN', name: 'Coinbase Global', sector: 'Financials', isETF: false },
|
||||||
|
{ symbol: 'NFLX', name: 'Netflix Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'CRM', name: 'Salesforce Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'ADBE', name: 'Adobe Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'NOW', name: 'ServiceNow Inc.', sector: 'Technology', isETF: false },
|
||||||
|
{ symbol: 'MSTR', name: 'MicroStrategy Inc.', sector: 'Technology', isETF: false },
|
||||||
|
// Broad ETFs
|
||||||
|
{ symbol: 'SPY', name: 'SPDR S&P 500 ETF', sector: 'ETF', isETF: true },
|
||||||
|
{ symbol: 'QQQ', name: 'Invesco QQQ Trust', sector: 'ETF', isETF: true },
|
||||||
|
{ symbol: 'IWM', name: 'iShares Russell 2000', sector: 'ETF', isETF: true },
|
||||||
|
{ symbol: 'DIA', name: 'SPDR Dow Jones ETF', sector: 'ETF', isETF: true },
|
||||||
|
// Sector ETFs
|
||||||
|
{ symbol: 'XLF', name: 'Financial Select SPDR', sector: 'ETF', isETF: true },
|
||||||
|
{ symbol: 'XLE', name: 'Energy Select SPDR', sector: 'ETF', isETF: true },
|
||||||
|
{ symbol: 'XLK', name: 'Technology Select SPDR', sector: 'ETF', isETF: true },
|
||||||
|
{ symbol: 'XLV', name: 'Health Care Select SPDR', sector: 'ETF', isETF: true },
|
||||||
|
// Specialty ETFs
|
||||||
|
{ symbol: 'ARKK', name: 'ARK Innovation ETF', sector: 'ETF', isETF: true },
|
||||||
|
{ symbol: 'GLD', name: 'SPDR Gold Shares', sector: 'ETF', isETF: true },
|
||||||
|
{ symbol: 'TLT', name: 'iShares 20+ Yr Treasury', sector: 'ETF', isETF: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify market cap tier
|
||||||
|
*/
|
||||||
|
export function classifyCapTier(marketCap, isETF = false) {
|
||||||
|
if (isETF) return 'ETF';
|
||||||
|
if (!marketCap || marketCap === 0) return 'UNKNOWN';
|
||||||
|
if (marketCap >= CAP_TIERS.LARGE) return 'LARGE';
|
||||||
|
if (marketCap >= CAP_TIERS.MID) return 'MID';
|
||||||
|
return 'SMALL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get universe symbols list
|
||||||
|
*/
|
||||||
|
export function getUniverseSymbols() {
|
||||||
|
return UNIVERSE.map(s => s.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get universe meta by symbol
|
||||||
|
*/
|
||||||
|
export function getSymbolMeta(symbol) {
|
||||||
|
return UNIVERSE.find(s => s.symbol === symbol.toUpperCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all universe entries
|
||||||
|
*/
|
||||||
|
export function getUniverse() {
|
||||||
|
return UNIVERSE;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* Yahoo Finance Crumb Helper
|
||||||
|
* Fetches and caches the A3 cookie and crumb needed for v10/v11 API endpoints
|
||||||
|
*/
|
||||||
|
|
||||||
|
let crumb = null;
|
||||||
|
let cookie = null;
|
||||||
|
let crumbPromise = null;
|
||||||
|
|
||||||
|
const DEFAULT_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchCrumbAndCookie() {
|
||||||
|
try {
|
||||||
|
// 1. Get Cookie
|
||||||
|
const response = await fetch('https://fc.yahoo.com', {
|
||||||
|
headers: DEFAULT_HEADERS,
|
||||||
|
redirect: 'manual' // We only need the headers
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCookieHeader = response.headers.get('set-cookie');
|
||||||
|
if (!setCookieHeader) {
|
||||||
|
throw new Error('No set-cookie header received from fc.yahoo.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract A3 cookie
|
||||||
|
const match = setCookieHeader.match(/(A3=[^;]+)/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('A3 cookie not found in set-cookie header');
|
||||||
|
}
|
||||||
|
cookie = match[1];
|
||||||
|
|
||||||
|
// 2. Get Crumb
|
||||||
|
const crumbRes = await fetch('https://query1.finance.yahoo.com/v1/test/getcrumb', {
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_HEADERS,
|
||||||
|
'Cookie': cookie,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!crumbRes.ok) {
|
||||||
|
throw new Error(`Failed to get crumb, status: ${crumbRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
crumb = await crumbRes.text();
|
||||||
|
return { cookie, crumb };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[YahooCrumb] Error fetching crumb:', err.message);
|
||||||
|
cookie = null;
|
||||||
|
crumb = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getYahooAuth() {
|
||||||
|
if (cookie && crumb) {
|
||||||
|
return { cookie, crumb };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!crumbPromise) {
|
||||||
|
crumbPromise = fetchCrumbAndCookie().finally(() => {
|
||||||
|
crumbPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function yfFetchAuth(url) {
|
||||||
|
const { cookie: currentCookie, crumb: currentCrumb } = await getYahooAuth();
|
||||||
|
|
||||||
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
|
const urlWithCrumb = `${url}${separator}crumb=${currentCrumb}`;
|
||||||
|
|
||||||
|
const res = await fetch(urlWithCrumb, {
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_HEADERS,
|
||||||
|
'Cookie': currentCookie,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// If auth fails, we might need a new crumb
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
crumb = null;
|
||||||
|
cookie = null;
|
||||||
|
throw new Error(`Auth failed (${res.status}), crumb invalidated`);
|
||||||
|
}
|
||||||
|
throw new Error(`Yahoo Finance HTTP ${res.status} for ${urlWithCrumb}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
@ -12,11 +12,9 @@
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"zustand": "^4.4.7",
|
"zustand": "^4.4.7",
|
||||||
"@tanstack/react-query": "^5.12.2",
|
"@tanstack/react-query": "^5.12.2",
|
||||||
"lightweight-charts": "^4.1.3",
|
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"d3": "^7.8.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import OptionsFlowPanel from '@/components/dashboard/OptionsFlowPanel';
|
import OptionsFlowPanel from '@/components/dashboard/OptionsFlowPanel';
|
||||||
import { DailyAnalysisPanel } from '@/components/dashboard/DailyAnalysisPanel';
|
|
||||||
import ScannerPanel from '@/components/dashboard/ScannerPanel';
|
import ScannerPanel from '@/components/dashboard/ScannerPanel';
|
||||||
import PhaseClassifierPanel from '@/components/dashboard/PhaseClassifierPanel';
|
|
||||||
import BacktestPanel from '@/components/dashboard/BacktestPanel';
|
import BacktestPanel from '@/components/dashboard/BacktestPanel';
|
||||||
import AlertsFeed from '@/components/dashboard/AlertsFeed';
|
import AlertsFeed from '@/components/dashboard/AlertsFeed';
|
||||||
import Watchlist from '@/components/dashboard/Watchlist';
|
import Watchlist from '@/components/dashboard/Watchlist';
|
||||||
import PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel';
|
import PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel';
|
||||||
import FlowInfoPanel from '@/components/dashboard/FlowInfoPanel';
|
|
||||||
import ReversalAlerts from '@/components/alerts/ReversalAlerts';
|
import ReversalAlerts from '@/components/alerts/ReversalAlerts';
|
||||||
import ConvergenceAlerts from '@/components/alerts/ConvergenceAlerts';
|
import ConvergenceAlerts from '@/components/alerts/ConvergenceAlerts';
|
||||||
|
import MarketScreenerPanel from '@/components/dashboard/MarketScreenerPanel';
|
||||||
|
import StockDetailPanel from '@/components/dashboard/StockDetailPanel';
|
||||||
|
import NewsFeedPanel from '@/components/dashboard/NewsFeedPanel';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [selectedSymbol, setSelectedSymbol] = useState(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
<ConvergenceAlerts />
|
<ConvergenceAlerts />
|
||||||
|
|
@ -46,17 +48,14 @@ export default function App() {
|
||||||
<div className="grid grid-cols-12 gap-6">
|
<div className="grid grid-cols-12 gap-6">
|
||||||
{/* Left: Main Panels */}
|
{/* Left: Main Panels */}
|
||||||
<div className="col-span-9">
|
<div className="col-span-9">
|
||||||
<Tabs defaultValue="flow" className="w-full">
|
<Tabs defaultValue="market" className="w-full">
|
||||||
<TabsList className="bg-slate-900 border border-slate-800">
|
<TabsList className="bg-slate-900 border border-slate-800">
|
||||||
|
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
|
||||||
|
📊 Market Analysis
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
|
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
|
||||||
🎯 Options Flow
|
🎯 Options Flow
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="phase" className="data-[state=active]:bg-slate-800">
|
|
||||||
🔥 Phase Classifier
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="daily" className="data-[state=active]:bg-slate-800">
|
|
||||||
📊 Daily Analysis
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
|
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
|
||||||
🔍 Multi-Signal Scanner
|
🔍 Multi-Signal Scanner
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -65,18 +64,20 @@ export default function App() {
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="market" className="mt-6 space-y-6">
|
||||||
|
<MarketScreenerPanel onSelectSymbol={setSelectedSymbol} />
|
||||||
|
{selectedSymbol && (
|
||||||
|
<StockDetailPanel
|
||||||
|
symbol={selectedSymbol}
|
||||||
|
onClose={() => setSelectedSymbol(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="flow" className="mt-6">
|
<TabsContent value="flow" className="mt-6">
|
||||||
<OptionsFlowPanel />
|
<OptionsFlowPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="phase" className="mt-6">
|
|
||||||
<PhaseClassifierPanel />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="daily" className="mt-6">
|
|
||||||
<DailyAnalysisPanel />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="scanner" className="mt-6">
|
<TabsContent value="scanner" className="mt-6">
|
||||||
<ScannerPanel />
|
<ScannerPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -87,15 +88,15 @@ export default function App() {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Flow Info, Watchlist & Alerts Feed */}
|
{/* Right: News, Watchlist & Alerts Feed */}
|
||||||
<div className="col-span-3 space-y-6">
|
<div className="col-span-3 space-y-6">
|
||||||
<FlowInfoPanel />
|
<NewsFeedPanel />
|
||||||
<Watchlist />
|
<Watchlist />
|
||||||
<AlertsFeed />
|
<AlertsFeed />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom: Today's Signals */}
|
{/* Bottom: Performance Tracking */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<PerformanceTrackingPanel />
|
<PerformanceTrackingPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1 @@
|
||||||
import { useEffect, useRef } from 'react';
|
export default function() { return null; }
|
||||||
import * as d3 from 'd3';
|
|
||||||
|
|
||||||
export default function FlowHeatmap({ data }) {
|
|
||||||
const svgRef = useRef();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || data.length === 0) return;
|
|
||||||
|
|
||||||
const margin = { top: 20, right: 20, bottom: 60, left: 60 };
|
|
||||||
const width = 800 - margin.left - margin.right;
|
|
||||||
const height = 400 - margin.top - margin.bottom;
|
|
||||||
|
|
||||||
// Clear previous
|
|
||||||
d3.select(svgRef.current).selectAll('*').remove();
|
|
||||||
|
|
||||||
const svg = d3
|
|
||||||
.select(svgRef.current)
|
|
||||||
.attr('width', width + margin.left + margin.right)
|
|
||||||
.attr('height', height + margin.top + margin.bottom)
|
|
||||||
.append('g')
|
|
||||||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
||||||
|
|
||||||
// Prepare data: group by strike and time
|
|
||||||
const strikeExtent = d3.extent(data, d => d.strike_num || d.strike || 0);
|
|
||||||
const timeExtent = d3.extent(data, d => new Date(d.flow_ts_utc || d.CreatedDate || d.created_at));
|
|
||||||
|
|
||||||
// X scale: time
|
|
||||||
const xScale = d3
|
|
||||||
.scaleTime()
|
|
||||||
.domain(timeExtent)
|
|
||||||
.range([0, width]);
|
|
||||||
|
|
||||||
// Y scale: strike
|
|
||||||
const yScale = d3
|
|
||||||
.scaleLinear()
|
|
||||||
.domain(strikeExtent)
|
|
||||||
.range([height, 0]);
|
|
||||||
|
|
||||||
// Color scale: premium
|
|
||||||
const colorScale = d3
|
|
||||||
.scaleSequential(d3.interpolateRdYlGn)
|
|
||||||
.domain([
|
|
||||||
d3.min(data, d => (d.direction === 'BEAR' ? -(d.premium_num || d.total_premium || 0) : (d.premium_num || d.total_premium || 0))),
|
|
||||||
d3.max(data, d => (d.direction === 'BULL' ? (d.premium_num || d.total_premium || 0) : -(d.premium_num || d.total_premium || 0))),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Draw rectangles
|
|
||||||
svg
|
|
||||||
.selectAll('rect')
|
|
||||||
.data(data)
|
|
||||||
.enter()
|
|
||||||
.append('rect')
|
|
||||||
.attr('x', d => xScale(new Date(d.flow_ts_utc || d.CreatedDate || d.created_at)))
|
|
||||||
.attr('y', d => yScale(d.strike_num || d.strike || 0))
|
|
||||||
.attr('width', 10)
|
|
||||||
.attr('height', 10)
|
|
||||||
.attr('fill', d =>
|
|
||||||
colorScale(d.direction === 'BULL' ? (d.premium_num || d.total_premium || 0) : -(d.premium_num || d.total_premium || 0))
|
|
||||||
)
|
|
||||||
.attr('opacity', 0.7)
|
|
||||||
.on('mouseover', function (event, d) {
|
|
||||||
d3.select(this).attr('opacity', 1);
|
|
||||||
// Show tooltip
|
|
||||||
})
|
|
||||||
.on('mouseout', function () {
|
|
||||||
d3.select(this).attr('opacity', 0.7);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add axes
|
|
||||||
svg
|
|
||||||
.append('g')
|
|
||||||
.attr('transform', `translate(0,${height})`)
|
|
||||||
.call(d3.axisBottom(xScale))
|
|
||||||
.style('color', '#94a3b8');
|
|
||||||
|
|
||||||
svg
|
|
||||||
.append('g')
|
|
||||||
.call(d3.axisLeft(yScale))
|
|
||||||
.style('color', '#94a3b8');
|
|
||||||
|
|
||||||
// Labels
|
|
||||||
svg
|
|
||||||
.append('text')
|
|
||||||
.attr('x', width / 2)
|
|
||||||
.attr('y', height + 50)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.style('fill', '#94a3b8')
|
|
||||||
.text('Time');
|
|
||||||
|
|
||||||
svg
|
|
||||||
.append('text')
|
|
||||||
.attr('transform', 'rotate(-90)')
|
|
||||||
.attr('x', -height / 2)
|
|
||||||
.attr('y', -50)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.style('fill', '#94a3b8')
|
|
||||||
.text('Strike Price');
|
|
||||||
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 bg-slate-900 rounded-lg border border-slate-800">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 text-slate-100">
|
|
||||||
Options Flow Heatmap
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-center h-[400px] text-slate-400">
|
|
||||||
No flow data available
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 bg-slate-900 rounded-lg border border-slate-800">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 text-slate-100">
|
|
||||||
Options Flow Heatmap
|
|
||||||
</h3>
|
|
||||||
<svg ref={svgRef} className="w-full" />
|
|
||||||
<div className="mt-4 flex items-center justify-center gap-4 text-xs text-slate-400">
|
|
||||||
<span>🟢 Bullish Premium</span>
|
|
||||||
<span>🔴 Bearish Premium</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,191 +1 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
export default function() { return null; }
|
||||||
import { createChart } from 'lightweight-charts';
|
|
||||||
import { getApiUrl } from '@/config/api';
|
|
||||||
|
|
||||||
export default function PriceChart({ symbol, flowData }) {
|
|
||||||
const chartContainerRef = useRef();
|
|
||||||
const chartRef = useRef();
|
|
||||||
const [timeframe, setTimeframe] = useState('1m');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chartContainerRef.current) return;
|
|
||||||
|
|
||||||
// Create chart
|
|
||||||
const chart = createChart(chartContainerRef.current, {
|
|
||||||
width: chartContainerRef.current.clientWidth,
|
|
||||||
height: 500,
|
|
||||||
layout: {
|
|
||||||
background: { color: '#0f172a' },
|
|
||||||
textColor: '#94a3b8',
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
vertLines: { color: '#1e293b' },
|
|
||||||
horzLines: { color: '#1e293b' },
|
|
||||||
},
|
|
||||||
crosshair: {
|
|
||||||
mode: 1,
|
|
||||||
},
|
|
||||||
timeScale: {
|
|
||||||
borderColor: '#334155',
|
|
||||||
timeVisible: true,
|
|
||||||
secondsVisible: false,
|
|
||||||
},
|
|
||||||
rightPriceScale: {
|
|
||||||
borderColor: '#334155',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main candlestick series
|
|
||||||
const candleSeries = chart.addCandlestickSeries({
|
|
||||||
upColor: '#22c55e',
|
|
||||||
downColor: '#ef4444',
|
|
||||||
borderUpColor: '#22c55e',
|
|
||||||
borderDownColor: '#ef4444',
|
|
||||||
wickUpColor: '#22c55e',
|
|
||||||
wickDownColor: '#ef4444',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Volume series
|
|
||||||
const volumeSeries = chart.addHistogramSeries({
|
|
||||||
color: '#3b82f6',
|
|
||||||
priceFormat: { type: 'volume' },
|
|
||||||
priceScaleId: 'volume',
|
|
||||||
});
|
|
||||||
|
|
||||||
chart.priceScale('volume').applyOptions({
|
|
||||||
scaleMargins: {
|
|
||||||
top: 0.8,
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Flow markers series (for options flow events)
|
|
||||||
const markerSeries = chart.addLineSeries({
|
|
||||||
color: 'transparent',
|
|
||||||
lineWidth: 0,
|
|
||||||
crosshairMarkerVisible: false,
|
|
||||||
lastValueVisible: false,
|
|
||||||
priceLineVisible: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
chartRef.current = {
|
|
||||||
chart,
|
|
||||||
candleSeries,
|
|
||||||
volumeSeries,
|
|
||||||
markerSeries,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch and set price data
|
|
||||||
fetchPriceData(symbol, timeframe).then(data => {
|
|
||||||
if (data && data.candles) {
|
|
||||||
candleSeries.setData(data.candles);
|
|
||||||
}
|
|
||||||
if (data && data.volume) {
|
|
||||||
volumeSeries.setData(data.volume);
|
|
||||||
}
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('Error fetching price data:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add flow markers
|
|
||||||
if (flowData && flowData.length > 0) {
|
|
||||||
const markers = flowData.map(flow => ({
|
|
||||||
time: Math.floor(new Date(flow.flow_ts_utc || flow.CreatedDate || flow.created_at).getTime() / 1000),
|
|
||||||
position: flow.direction === 'BULL' ? 'belowBar' : 'aboveBar',
|
|
||||||
color: flow.direction === 'BULL' ? '#22c55e' : '#ef4444',
|
|
||||||
shape: flow.rocketScore >= 5 ? 'arrowUp' : 'circle',
|
|
||||||
text: `${flow.rocketDisplay || '🚀'} $${((flow.premium_num || flow.total_premium || 0) / 1000).toFixed(0)}K`,
|
|
||||||
size: (flow.premium_num || flow.total_premium || 0) > 1000000 ? 2 : 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
markerSeries.setMarkers(markers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle resize
|
|
||||||
const handleResize = () => {
|
|
||||||
if (chartContainerRef.current && chartRef.current) {
|
|
||||||
chartRef.current.chart.applyOptions({
|
|
||||||
width: chartContainerRef.current.clientWidth,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
if (chartRef.current) {
|
|
||||||
chartRef.current.chart.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [symbol, timeframe, flowData]);
|
|
||||||
|
|
||||||
if (!symbol) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-[500px] border border-slate-800 rounded-lg bg-slate-900/50">
|
|
||||||
<div className="text-slate-400">
|
|
||||||
Select a symbol to view price chart
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Timeframe Selector */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{['1m', '5m', '15m', '1h', '1D'].map(tf => (
|
|
||||||
<button
|
|
||||||
key={tf}
|
|
||||||
onClick={() => setTimeframe(tf)}
|
|
||||||
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
|
||||||
timeframe === tf
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tf}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart Container */}
|
|
||||||
<div
|
|
||||||
ref={chartContainerRef}
|
|
||||||
className="relative rounded-lg border border-slate-800 bg-slate-900/50"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Flow Legend */}
|
|
||||||
<div className="flex items-center gap-4 text-xs text-slate-400">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
|
||||||
<span>Bullish Flow</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
|
||||||
<span>Bearish Flow</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 bg-yellow-500" />
|
|
||||||
<span>🚀🚀🚀 High Score</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPriceData(symbol, timeframe) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${getApiUrl(`/api/prices/${symbol}`)}?timeframe=${timeframe}`
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching price data:', error);
|
|
||||||
return { candles: [], volume: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,154 +1,611 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { TrendingUp, TrendingDown, BarChart3, RefreshCw } from 'lucide-react';
|
import { TrendingUp, TrendingDown, RefreshCw, Calendar, Search, BarChart3 } from 'lucide-react';
|
||||||
import { getApiUrl } from '@/config/api';
|
import { getApiUrl } from '@/config/api';
|
||||||
|
|
||||||
export default function BacktestPanel() {
|
export default function BacktestPanel() {
|
||||||
const [results, setResults] = useState([]);
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [minPremium, setMinPremium] = useState(80000);
|
||||||
|
const [symbols, setSymbols] = useState([]);
|
||||||
|
const [selectedSymbol, setSelectedSymbol] = useState(null);
|
||||||
|
const [performance, setPerformance] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingPerformance, setLoadingPerformance] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Fetch symbols for selected date
|
||||||
fetchBacktests();
|
const fetchSymbolsForDate = async (date, premium = minPremium) => {
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchBacktests = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getApiUrl('/api/backtest/presets'));
|
const response = await fetch(
|
||||||
|
getApiUrl(`/api/backtest/date-analysis?date=${date}&minRocketScore=2&maxRocketScore=3&minPremium=${premium}`)
|
||||||
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setResults(data.results);
|
// Filter symbols that appeared more than 3 times (using total_trades or signal_periods)
|
||||||
|
const frequentSymbols = data.symbols.filter(s => (s.total_trades || s.signal_count || 0) > 3);
|
||||||
|
setSymbols(frequentSymbols);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Backtest fetch error:', error);
|
console.error('Error fetching symbols:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
// Fetch performance for selected symbol
|
||||||
return (
|
const fetchSymbolPerformance = async (symbol, date) => {
|
||||||
<div className="text-center py-12">
|
setLoadingPerformance(true);
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
setSelectedSymbol(symbol);
|
||||||
<div className="mt-4 text-slate-400">Running backtests...</div>
|
try {
|
||||||
</div>
|
const response = await fetch(
|
||||||
);
|
getApiUrl(`/api/backtest/symbol-performance?symbol=${symbol}&entryDate=${date}&daysForward=7`)
|
||||||
}
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setPerformance(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching performance:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingPerformance(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
fetchSymbolsForDate(selectedDate, minPremium);
|
||||||
|
}
|
||||||
|
}, [selectedDate, minPremium]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold">Strategy Backtests (90 Days)</h2>
|
<h2 className="text-2xl font-bold">Historical Signal Backtest</h2>
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
onClick={fetchBacktests}
|
<div className="flex items-center gap-2">
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors flex items-center gap-2"
|
<Calendar className="w-4 h-4 text-slate-400" />
|
||||||
disabled={loading}
|
<input
|
||||||
>
|
type="date"
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
value={selectedDate}
|
||||||
Refresh
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
</button>
|
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200"
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-400 whitespace-nowrap">Min Premium:</span>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 text-sm">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={minPremium}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value) || 0;
|
||||||
|
setMinPremium(value);
|
||||||
|
}}
|
||||||
|
className="pl-7 pr-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 w-32"
|
||||||
|
placeholder="80000"
|
||||||
|
min="0"
|
||||||
|
step="1000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchSymbolsForDate(selectedDate, minPremium)}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length === 0 && !loading && (
|
{/* Date Selection Info */}
|
||||||
<div className="text-center py-12 text-slate-400">
|
<div className="p-4 bg-slate-900 border border-slate-800 rounded-lg">
|
||||||
No backtest results available. Try refreshing or check the backend logs.
|
<p className="text-sm text-slate-400">
|
||||||
|
📅 Analyzing signals from <span className="font-semibold text-slate-200">{selectedDate}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Showing symbols with 2-3 rocket scores that had more than 3 option trades with premium > ${(minPremium / 1000).toFixed(0)}K
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Left: Symbols List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Search className="w-5 h-5" />
|
||||||
|
Symbols with High Signal Activity
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
|
<div className="mt-4 text-slate-400">Loading symbols...</div>
|
||||||
|
</div>
|
||||||
|
) : symbols.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-400">
|
||||||
|
No symbols found for this date. Try a different date.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||||
|
{symbols.map((symbol) => (
|
||||||
|
<div
|
||||||
|
key={symbol.symbol_norm}
|
||||||
|
onClick={() => fetchSymbolPerformance(symbol.symbol_norm, selectedDate)}
|
||||||
|
className={`p-4 bg-slate-900 border rounded-lg cursor-pointer transition-all ${
|
||||||
|
selectedSymbol === symbol.symbol_norm
|
||||||
|
? 'border-blue-500 bg-slate-800'
|
||||||
|
: 'border-slate-800 hover:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-lg font-bold text-slate-200">{symbol.symbol_norm}</h4>
|
||||||
|
<Badge
|
||||||
|
variant={(symbol.total_trades || symbol.signal_count || 0) > 10 ? 'success' : 'warning'}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{symbol.total_trades || symbol.signal_count || 0} trades
|
||||||
|
{symbol.signal_periods ? ` (${symbol.signal_periods} periods)` : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Total Premium:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
${((Number(symbol.total_premium) || 0) / 1000000).toFixed(2)}M
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Avg Rocket:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
{symbol.avg_rocket_score != null ? Number(symbol.avg_rocket_score).toFixed(1) : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Entry Price:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
{symbol.entry_price != null ? `$${Number(symbol.entry_price).toFixed(2)}` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Direction:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
{symbol.directions || 'Mixed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BULL/BEAR Breakdown */}
|
||||||
|
{(symbol.bull_count > 0 || symbol.bear_count > 0) && (
|
||||||
|
<div className="pt-3 border-t border-slate-700 space-y-2">
|
||||||
|
{symbol.bull_count > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-green-400 font-semibold">🟢 BULL:</span>
|
||||||
|
<span className="text-slate-300">{symbol.bull_count} signals</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-slate-200 font-semibold">
|
||||||
|
${((Number(symbol.bull_premium) || 0) / 1000000).toFixed(2)}M
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400 text-xs">
|
||||||
|
${((Number(symbol.bull_min_premium) || 0) / 1000).toFixed(0)}K - ${((Number(symbol.bull_max_premium) || 0) / 1000).toFixed(0)}K
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{symbol.bear_count > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-red-400 font-semibold">🔴 BEAR:</span>
|
||||||
|
<span className="text-slate-300">{symbol.bear_count} signals</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-slate-200 font-semibold">
|
||||||
|
${((Number(symbol.bear_premium) || 0) / 1000000).toFixed(2)}M
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400 text-xs">
|
||||||
|
${((Number(symbol.bear_min_premium) || 0) / 1000).toFixed(0)}K - ${((Number(symbol.bear_max_premium) || 0) / 1000).toFixed(0)}K
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Predictive Factors */}
|
||||||
|
<div className="pt-3 border-t border-slate-700 space-y-2">
|
||||||
|
<h6 className="text-xs font-semibold text-slate-400 mb-2">📊 Predictive Factors:</h6>
|
||||||
|
|
||||||
|
{/* Moneyness */}
|
||||||
|
{(symbol.itm_count > 0 || symbol.otm_count > 0) && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Moneyness:</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-green-400">{symbol.itm_count || 0} ITM</span>
|
||||||
|
<span className="text-slate-500 mx-1">/</span>
|
||||||
|
<span className="text-yellow-400">{symbol.otm_count || 0} OTM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DTE */}
|
||||||
|
{symbol.avg_dte != null && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Avg DTE:</span>
|
||||||
|
<span className="text-slate-200 font-semibold">
|
||||||
|
{Number(symbol.avg_dte).toFixed(0)} days
|
||||||
|
{symbol.most_common_dte_bucket && (
|
||||||
|
<span className="text-slate-400 ml-1">({symbol.most_common_dte_bucket})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session Distribution */}
|
||||||
|
{(symbol.rth_count > 0 || symbol.pre_count > 0 || symbol.post_count > 0) && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Session:</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-blue-400">{symbol.rth_count || 0} RTH</span>
|
||||||
|
{symbol.pre_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500 mx-1">/</span>
|
||||||
|
<span className="text-purple-400">{symbol.pre_count} PRE</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{symbol.post_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500 mx-1">/</span>
|
||||||
|
<span className="text-orange-400">{symbol.post_count} POST</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Catalyst */}
|
||||||
|
{symbol.catalyst_count > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">⚡ Catalyst:</span>
|
||||||
|
<span className="text-yellow-400 font-semibold">
|
||||||
|
{symbol.catalyst_count} signals ({Number(symbol.pct_with_catalyst || 0).toFixed(0)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Volume/OI Ratio */}
|
||||||
|
{symbol.avg_vol_oi_ratio != null && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Vol/OI Ratio:</span>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
Number(symbol.avg_vol_oi_ratio) > 1 ? 'text-green-400' : 'text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{Number(symbol.avg_vol_oi_ratio).toFixed(2)}x
|
||||||
|
{symbol.vol_exceeds_oi_count > 0 && (
|
||||||
|
<span className="text-slate-400 ml-1">
|
||||||
|
({symbol.vol_exceeds_oi_count} trades)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Strike Clustering */}
|
||||||
|
{symbol.unique_strikes > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Strike Clustering:</span>
|
||||||
|
<span className="text-slate-200 font-semibold">
|
||||||
|
{symbol.unique_strikes} unique strikes
|
||||||
|
{symbol.avg_trades_per_strike > 0 && (
|
||||||
|
<span className="text-slate-400 ml-1">
|
||||||
|
({Number(symbol.avg_trades_per_strike).toFixed(1)} avg/strike)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{symbol.max_trades_at_single_strike > 1 && (
|
||||||
|
<span className="text-blue-400 ml-1">
|
||||||
|
(max: {symbol.max_trades_at_single_strike})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* IV */}
|
||||||
|
{symbol.avg_iv != null && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Avg IV:</span>
|
||||||
|
<span className="text-slate-200 font-semibold">
|
||||||
|
{Number(symbol.avg_iv).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
{/* Right: Performance Analysis */}
|
||||||
{results.map((result, idx) => (
|
<div className="space-y-4">
|
||||||
<div
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
key={idx}
|
<BarChart3 className="w-5 h-5" />
|
||||||
className="p-6 bg-slate-900 border border-slate-800 rounded-lg"
|
Performance Analysis
|
||||||
>
|
</h3>
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">{result.name}</h3>
|
{loadingPerformance ? (
|
||||||
<Badge
|
<div className="text-center py-12">
|
||||||
variant={parseFloat(result.winRate) >= 60 ? 'success' : parseFloat(result.winRate) >= 50 ? 'warning' : 'destructive'}
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
className="text-lg px-3 py-1"
|
<div className="mt-4 text-slate-400">Analyzing performance...</div>
|
||||||
>
|
|
||||||
{result.winRate}% Win Rate
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : !performance ? (
|
||||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
<div className="text-center py-12 text-slate-400">
|
||||||
<div className="text-center p-3 bg-slate-800/50 rounded">
|
Select a symbol to view performance analysis
|
||||||
<div className="text-2xl font-bold text-blue-400">
|
|
||||||
{result.totalOccurrences || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 mt-1">Total Signals</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-slate-800/50 rounded">
|
|
||||||
<div className="text-2xl font-bold text-green-400">
|
|
||||||
{result.avgWin || '0.00'}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 mt-1">Avg Win</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-slate-800/50 rounded">
|
|
||||||
<div className="text-2xl font-bold text-red-400">
|
|
||||||
{result.avgLoss || '0.00'}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 mt-1">Avg Loss</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-slate-800/50 rounded">
|
|
||||||
<div className={`text-2xl font-bold ${parseFloat(result.expectancy || 0) >= 0 ? 'text-purple-400' : 'text-orange-400'}`}>
|
|
||||||
{result.expectancy || '0.00'}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 mt-1">Expectancy</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="space-y-4">
|
||||||
<div>
|
{/* Summary Card */}
|
||||||
<div className="text-sm font-semibold text-green-400 mb-2 flex items-center gap-2">
|
<div className={`p-6 rounded-lg border-2 ${
|
||||||
<TrendingUp className="w-4 h-4" />
|
performance.performance.wouldBeProfitable
|
||||||
Best Conditions
|
? 'bg-green-900/20 border-green-600/50'
|
||||||
</div>
|
: 'bg-red-900/20 border-red-600/50'
|
||||||
<ul className="space-y-1 text-xs text-slate-300">
|
}`}>
|
||||||
{result.bestConditions && result.bestConditions.length > 0 ? (
|
<div className="flex items-center justify-between mb-4">
|
||||||
result.bestConditions.map((cond, i) => (
|
<h4 className="text-xl font-bold">{performance.symbol}</h4>
|
||||||
<li key={i} className="flex items-center gap-2">
|
{performance.performance.wouldBeProfitable ? (
|
||||||
<span className="text-green-400">•</span>
|
<TrendingUp className="w-8 h-8 text-green-400" />
|
||||||
<span>{cond.condition}:</span>
|
|
||||||
<span className="font-semibold text-green-400">{cond.winRate}</span>
|
|
||||||
<span className="text-slate-500">({cond.occurrences} trades)</span>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<li className="text-slate-500">No data available</li>
|
<TrendingDown className="w-8 h-8 text-red-400" />
|
||||||
)}
|
)}
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-red-400 mb-2 flex items-center gap-2">
|
|
||||||
<TrendingDown className="w-4 h-4" />
|
|
||||||
Worst Conditions
|
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-1 text-xs text-slate-300">
|
|
||||||
{result.worstConditions && result.worstConditions.length > 0 ? (
|
<div className="space-y-2">
|
||||||
result.worstConditions.map((cond, i) => (
|
<div className="flex justify-between">
|
||||||
<li key={i} className="flex items-center gap-2">
|
<span className="text-slate-400">Entry Price:</span>
|
||||||
<span className="text-red-400">•</span>
|
<span className="font-semibold">${Number(performance.entryPrice || 0).toFixed(2)}</span>
|
||||||
<span>{cond.condition}:</span>
|
</div>
|
||||||
<span className="font-semibold text-red-400">{cond.winRate}</span>
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-500">({cond.occurrences} trades)</span>
|
<span className="text-slate-400">Final Change:</span>
|
||||||
</li>
|
<span className={`font-semibold ${
|
||||||
))
|
Number(performance.performance?.finalGain || 0) >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
) : (
|
}`}>
|
||||||
<li className="text-slate-500">No data available</li>
|
{Number(performance.performance?.finalGain || 0) >= 0 ? '+' : ''}
|
||||||
)}
|
{Number(performance.performance?.finalGain || 0).toFixed(2)}%
|
||||||
</ul>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Max Gain:</span>
|
||||||
|
<span className="font-semibold text-green-400">
|
||||||
|
+{Number(performance.performance?.maxGain || 0).toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Max Loss:</span>
|
||||||
|
<span className="font-semibold text-red-400">
|
||||||
|
{Number(performance.performance?.maxLoss || 0).toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{result.error && (
|
<div className="mt-4 p-3 bg-slate-800/50 rounded">
|
||||||
<div className="mt-4 p-3 bg-red-900/20 border border-red-600/50 rounded text-sm text-red-400">
|
<p className="text-sm text-slate-300">
|
||||||
⚠️ Error: {result.error}
|
{performance.performance.profitReason}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Detailed Analysis for Mixed Signals */}
|
||||||
|
{performance.performance.detailedAnalysis && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-700">
|
||||||
|
<h6 className="text-xs font-semibold text-slate-400 mb-3">Mixed Signals Breakdown:</h6>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-xs text-slate-500 w-16">BULL:</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className={`text-xs font-semibold ${
|
||||||
|
performance.performance.detailedAnalysis.bullSignals.profitable
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{performance.performance.detailedAnalysis.bullSignals.result}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{performance.performance.detailedAnalysis.bullSignals.explanation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-xs text-slate-500 w-16">BEAR:</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className={`text-xs font-semibold ${
|
||||||
|
performance.performance.detailedAnalysis.bearSignals.profitable
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{performance.performance.detailedAnalysis.bearSignals.result}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{performance.performance.detailedAnalysis.bearSignals.explanation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-2 border-t border-slate-700">
|
||||||
|
<p className="text-xs font-semibold text-blue-400">
|
||||||
|
💡 {performance.performance.detailedAnalysis.recommendation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Price Chart */}
|
||||||
))}
|
{performance.priceHistory && performance.priceHistory.length > 0 && (
|
||||||
|
<div className="p-4 bg-slate-900 border border-slate-800 rounded-lg">
|
||||||
|
<h5 className="text-sm font-semibold mb-3 text-slate-300">Price Movement (7 Days)</h5>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{performance.priceHistory.map((day) => (
|
||||||
|
<div key={day.date} className="flex items-center gap-2">
|
||||||
|
<div className="w-20 text-xs text-slate-400">
|
||||||
|
{new Date(day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-slate-800 rounded h-6 relative">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded ${
|
||||||
|
Number(day.changePct || 0) >= 0 ? 'bg-green-500/50' : 'bg-red-500/50'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(Math.abs(Number(day.changePct || 0)) * 10, 100)}%`,
|
||||||
|
marginLeft: Number(day.changePct || 0) < 0 ? 'auto' : '0',
|
||||||
|
marginRight: Number(day.changePct || 0) < 0 ? '0' : 'auto'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`w-16 text-xs text-right font-semibold ${
|
||||||
|
Number(day.changePct || 0) >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{Number(day.changePct || 0) >= 0 ? '+' : ''}{Number(day.changePct || 0).toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<div className="w-20 text-xs text-slate-300 text-right">
|
||||||
|
${Number(day.close || 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signal Details */}
|
||||||
|
{performance.signals && (
|
||||||
|
<div className="p-4 bg-slate-900 border border-slate-800 rounded-lg">
|
||||||
|
<h5 className="text-sm font-semibold mb-3 text-slate-300">Signal Details</h5>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Signal Count:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
{performance.signals.signal_count || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Total Premium:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
${((Number(performance.signals.total_premium) || 0) / 1000000).toFixed(2)}M
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Max Rocket:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
{performance.signals.max_rocket_score != null ? Number(performance.signals.max_rocket_score).toFixed(1) : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Direction:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
{performance.signals.directions || 'Mixed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Predictive Factors in Signal Details */}
|
||||||
|
{(performance.signals.itm_count > 0 || performance.signals.otm_count > 0 ||
|
||||||
|
performance.signals.avg_dte != null || performance.signals.catalyst_count > 0) && (
|
||||||
|
<div className="pt-3 border-t border-slate-700 space-y-2">
|
||||||
|
<h6 className="text-xs font-semibold text-slate-400 mb-2">📊 Signal Quality Factors:</h6>
|
||||||
|
|
||||||
|
{/* Moneyness */}
|
||||||
|
{(performance.signals.itm_count > 0 || performance.signals.otm_count > 0) && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Moneyness:</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-green-400">{performance.signals.itm_count || 0} ITM</span>
|
||||||
|
<span className="text-slate-500 mx-1">/</span>
|
||||||
|
<span className="text-yellow-400">{performance.signals.otm_count || 0} OTM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DTE */}
|
||||||
|
{performance.signals.avg_dte != null && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Avg DTE:</span>
|
||||||
|
<span className="text-slate-200 font-semibold">
|
||||||
|
{Number(performance.signals.avg_dte).toFixed(0)} days
|
||||||
|
{performance.signals.most_common_dte_bucket && (
|
||||||
|
<span className="text-slate-400 ml-1">({performance.signals.most_common_dte_bucket})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session Distribution */}
|
||||||
|
{(performance.signals.rth_count > 0 || performance.signals.pre_count > 0 || performance.signals.post_count > 0) && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Session:</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-blue-400">{performance.signals.rth_count || 0} RTH</span>
|
||||||
|
{performance.signals.pre_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500 mx-1">/</span>
|
||||||
|
<span className="text-purple-400">{performance.signals.pre_count} PRE</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{performance.signals.post_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500 mx-1">/</span>
|
||||||
|
<span className="text-orange-400">{performance.signals.post_count} POST</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Catalyst */}
|
||||||
|
{performance.signals.catalyst_count > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">⚡ Catalyst:</span>
|
||||||
|
<span className="text-yellow-400 font-semibold">
|
||||||
|
{performance.signals.catalyst_count} signals ({Number(performance.signals.pct_with_catalyst || 0).toFixed(0)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Volume/OI Ratio */}
|
||||||
|
{performance.signals.avg_vol_oi_ratio != null && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Vol/OI Ratio:</span>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
Number(performance.signals.avg_vol_oi_ratio) > 1 ? 'text-green-400' : 'text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{Number(performance.signals.avg_vol_oi_ratio).toFixed(2)}x
|
||||||
|
{performance.signals.vol_exceeds_oi_count > 0 && (
|
||||||
|
<span className="text-slate-400 ml-1">
|
||||||
|
({performance.signals.vol_exceeds_oi_count} trades)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Strike Clustering */}
|
||||||
|
{performance.signals.unique_strikes > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Strike Clustering:</span>
|
||||||
|
<span className="text-slate-200 font-semibold">
|
||||||
|
{performance.signals.unique_strikes} unique strikes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,300 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3010';
|
||||||
|
|
||||||
|
const CAP_TABS = [
|
||||||
|
{ id: 'all', label: '🌐 All', color: 'text-slate-300' },
|
||||||
|
{ id: 'large', label: '🏛️ Large Cap', color: 'text-blue-400' },
|
||||||
|
{ id: 'mid', label: '🏢 Mid Cap', color: 'text-purple-400' },
|
||||||
|
{ id: 'small', label: '🔬 Small Cap', color: 'text-orange-400' },
|
||||||
|
{ id: 'etf', label: '📦 ETFs', color: 'text-green-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtMktCap(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (n >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
|
||||||
|
if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
|
||||||
|
if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
|
||||||
|
return `$${n.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtVolume(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
||||||
|
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
||||||
|
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
|
||||||
|
return `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeBadge({ value }) {
|
||||||
|
if (value == null) return <span className="text-slate-500">—</span>;
|
||||||
|
const positive = value >= 0;
|
||||||
|
return (
|
||||||
|
<span className={`font-semibold tabular-nums ${
|
||||||
|
positive ? 'text-emerald-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{positive ? '+' : ''}{value.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapBadge({ tier }) {
|
||||||
|
const styles = {
|
||||||
|
LARGE: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
|
MID: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||||
|
SMALL: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||||
|
ETF: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||||
|
UNKNOWN: 'bg-slate-700 text-slate-400 border-slate-600',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
|
||||||
|
styles[tier] || styles.UNKNOWN
|
||||||
|
}`}>
|
||||||
|
{tier}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarketScreenerPanel({ onSelectSymbol }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
const [sortBy, setSortBy] = useState('market_cap');
|
||||||
|
const [sortDir, setSortDir] = useState('desc');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [leaderboard, setLeaderboard] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cap = activeTab === 'all' ? '' : activeTab;
|
||||||
|
const url = `${API_BASE}/api/market/universe${cap ? `?cap=${cap}` : ''}`;
|
||||||
|
const [universeRes, lbRes] = await Promise.allSettled([
|
||||||
|
fetch(url).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE}/api/market/leaderboard`).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (universeRes.status === 'fulfilled' && universeRes.value.success) {
|
||||||
|
setData(universeRes.value.data || []);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
}
|
||||||
|
if (lbRes.status === 'fulfilled' && lbRes.value.success) {
|
||||||
|
setLeaderboard(lbRes.value.data);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetchData();
|
||||||
|
const interval = setInterval(fetchData, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const processed = [...data]
|
||||||
|
.filter(r => {
|
||||||
|
if (!search) return true;
|
||||||
|
const q = search.toUpperCase();
|
||||||
|
return r.symbol.includes(q) || r.name.toUpperCase().includes(q) || r.sector?.toUpperCase().includes(q);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const av = a[sortBy] ?? -Infinity;
|
||||||
|
const bv = b[sortBy] ?? -Infinity;
|
||||||
|
return sortDir === 'desc' ? bv - av : av - bv;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSort = (col) => {
|
||||||
|
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc');
|
||||||
|
else { setSortBy(col); setSortDir('desc'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortIcon = ({ col }) => {
|
||||||
|
if (sortBy !== col) return <span className="text-slate-600 ml-1">⇅</span>;
|
||||||
|
return <span className="text-blue-400 ml-1">{sortDir === 'desc' ? '↓' : '↑'}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Leaderboard strip */}
|
||||||
|
{leaderboard && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<LeaderCard title="🚀 Top Gainers" items={leaderboard.top_gainers} type="gain" />
|
||||||
|
<LeaderCard title="📉 Top Losers" items={leaderboard.top_losers} type="loss" />
|
||||||
|
<LeaderCard title="🔥 Most Active" items={leaderboard.most_active} type="volume" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main screener card */}
|
||||||
|
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-white font-bold text-lg">📊 Market Screener</h2>
|
||||||
|
{lastUpdated && (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Updated {lastUpdated.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{loading && <span className="text-xs text-blue-400 animate-pulse">Refreshing...</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search symbol or name..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 w-52"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 border border-slate-600 text-slate-300 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cap tier tabs */}
|
||||||
|
<div className="flex border-b border-slate-700/50 bg-slate-950/30">
|
||||||
|
{CAP_TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium transition-all ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? `border-b-2 border-blue-500 ${tab.color} bg-blue-500/5`
|
||||||
|
: 'text-slate-500 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{error ? (
|
||||||
|
<div className="p-8 text-center text-red-400">
|
||||||
|
<p>⚠️ {error}</p>
|
||||||
|
<button onClick={fetchData} className="mt-2 text-blue-400 underline text-sm">Retry</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-500 text-xs uppercase tracking-wide bg-slate-950/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Symbol</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Sector</th>
|
||||||
|
<th
|
||||||
|
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||||
|
onClick={() => toggleSort('price')}
|
||||||
|
>Price <SortIcon col="price" /></th>
|
||||||
|
<th
|
||||||
|
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||||
|
onClick={() => toggleSort('change_pct')}
|
||||||
|
>Change <SortIcon col="change_pct" /></th>
|
||||||
|
<th
|
||||||
|
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||||
|
onClick={() => toggleSort('market_cap')}
|
||||||
|
>Mkt Cap <SortIcon col="market_cap" /></th>
|
||||||
|
<th
|
||||||
|
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||||
|
onClick={() => toggleSort('volume')}
|
||||||
|
>Volume <SortIcon col="volume" /></th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">Cap Tier</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
|
{loading && data.length === 0 ? (
|
||||||
|
Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<tr key={i} className="animate-pulse">
|
||||||
|
{Array.from({ length: 8 }).map((_, j) => (
|
||||||
|
<td key={j} className="px-4 py-3">
|
||||||
|
<div className="h-4 bg-slate-800 rounded w-full" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : processed.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} className="px-4 py-12 text-center text-slate-500">No results found</td></tr>
|
||||||
|
) : (
|
||||||
|
processed.map(row => (
|
||||||
|
<tr
|
||||||
|
key={row.symbol}
|
||||||
|
onClick={() => onSelectSymbol?.(row.symbol)}
|
||||||
|
className="hover:bg-slate-800/40 cursor-pointer transition-colors group"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-bold text-white group-hover:text-blue-400 transition-colors">
|
||||||
|
{row.symbol}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400 max-w-[180px] truncate">{row.name}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded">
|
||||||
|
{row.sector}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{row.price != null ? `$${row.price.toFixed(2)}` : '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<ChangeBadge value={row.change_pct} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-slate-300 tabular-nums">
|
||||||
|
{fmtMktCap(row.market_cap)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-slate-400 tabular-nums">
|
||||||
|
{fmtVolume(row.volume)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<CapBadge tier={row.capTier} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-2 border-t border-slate-700/50 bg-slate-950/30 flex justify-between items-center">
|
||||||
|
<span className="text-xs text-slate-600">{processed.length} of {data.length} symbols</span>
|
||||||
|
<span className="text-xs text-slate-600">Powered by Yahoo Finance • Free data • 60s refresh</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeaderCard({ title, items = [], type }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-3">
|
||||||
|
<div className="text-xs font-semibold text-slate-400 mb-2">{title}</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.slice(0, 5).map(item => (
|
||||||
|
<div key={item.symbol} className="flex items-center justify-between">
|
||||||
|
<span className="text-white font-medium text-sm">{item.symbol}</span>
|
||||||
|
{type === 'volume' ? (
|
||||||
|
<span className="text-slate-400 text-xs tabular-nums">
|
||||||
|
{item.volume >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ChangeBadge value={item.change_pct} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <div className="text-slate-600 text-xs">Loading...</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3010';
|
||||||
|
|
||||||
|
function SentimentDot({ sentiment }) {
|
||||||
|
if (sentiment === 'BULLISH') return <span className="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-2 flex-shrink-0" />;
|
||||||
|
if (sentiment === 'BEARISH') return <span className="inline-block w-2 h-2 rounded-full bg-red-400 mr-2 flex-shrink-0" />;
|
||||||
|
return <span className="inline-block w-2 h-2 rounded-full bg-slate-500 mr-2 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsFeedPanel() {
|
||||||
|
const [news, setNews] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchNews = () => {
|
||||||
|
fetch(`${API_BASE}/api/market/news`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (d.success) setNews(d.data || []); })
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
fetchNews();
|
||||||
|
const interval = setInterval(fetchNews, 300000); // 5min
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5 border-b border-slate-700/50">
|
||||||
|
<h3 className="text-sm font-semibold text-white">📰 Market News</h3>
|
||||||
|
{loading && <span className="text-xs text-blue-400 animate-pulse">Loading...</span>}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-800/30 max-h-[400px] overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="p-3 animate-pulse">
|
||||||
|
<div className="h-3 bg-slate-800 rounded mb-1.5" />
|
||||||
|
<div className="h-3 bg-slate-800 rounded w-2/3" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : news.length === 0 ? (
|
||||||
|
<div className="p-4 text-slate-500 text-xs text-center">No news available</div>
|
||||||
|
) : (
|
||||||
|
news.map((item, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-start gap-2 p-3 hover:bg-slate-800/40 transition-colors group"
|
||||||
|
>
|
||||||
|
<SentimentDot sentiment={item.sentiment} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-slate-300 group-hover:text-white transition-colors line-clamp-2 leading-snug">
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-600 mt-1">{item.source}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -291,28 +291,28 @@ export function OptionsFlowCardList({ data, onCardClick, selectedRow, reversalsB
|
||||||
{row.stockPrice && row.stockPrice.currentPrice && (
|
{row.stockPrice && row.stockPrice.currentPrice && (
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="font-mono text-base font-semibold text-slate-200">
|
<span className="font-mono text-base font-semibold text-slate-200">
|
||||||
${row.stockPrice.currentPrice.toFixed(2)}
|
${(typeof row.stockPrice.currentPrice === 'number' ? row.stockPrice.currentPrice : parseFloat(row.stockPrice.currentPrice) || 0).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
{row.stockPrice.changePercent !== undefined && (
|
{row.stockPrice.changePercent !== undefined && row.stockPrice.changePercent !== null && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-sm font-semibold",
|
"text-sm font-semibold",
|
||||||
row.stockPrice.changePercent >= 0
|
(typeof row.stockPrice.changePercent === 'number' ? row.stockPrice.changePercent : parseFloat(row.stockPrice.changePercent) || 0) >= 0
|
||||||
? "text-green-400"
|
? "text-green-400"
|
||||||
: "text-red-400"
|
: "text-red-400"
|
||||||
)}>
|
)}>
|
||||||
{row.stockPrice.changePercent >= 0 ? '+' : ''}
|
{(typeof row.stockPrice.changePercent === 'number' ? row.stockPrice.changePercent : parseFloat(row.stockPrice.changePercent) || 0) >= 0 ? '+' : ''}
|
||||||
{row.stockPrice.changePercent.toFixed(2)}%
|
{(typeof row.stockPrice.changePercent === 'number' ? row.stockPrice.changePercent : parseFloat(row.stockPrice.changePercent) || 0).toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{row.stockPrice.change !== undefined && (
|
{row.stockPrice.change !== undefined && row.stockPrice.change !== null && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-sm",
|
"text-sm",
|
||||||
row.stockPrice.change >= 0
|
(typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0) >= 0
|
||||||
? "text-green-400"
|
? "text-green-400"
|
||||||
: "text-red-400"
|
: "text-red-400"
|
||||||
)}>
|
)}>
|
||||||
({row.stockPrice.change >= 0 ? '+' : ''}
|
({(typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0) >= 0 ? '+' : ''}
|
||||||
{row.stockPrice.change.toFixed(2)})
|
{(typeof row.stockPrice.change === 'number' ? row.stockPrice.change : parseFloat(row.stockPrice.change) || 0).toFixed(2)})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -446,6 +446,197 @@ export function OptionsFlowCardList({ data, onCardClick, selectedRow, reversalsB
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Phase 1: Institutional Analytics */}
|
||||||
|
{(row.confidence_score !== null && row.confidence_score !== undefined) ||
|
||||||
|
(row.signal_strength !== null && row.signal_strength !== undefined) ||
|
||||||
|
(row.relative_premium_score !== null && row.relative_premium_score !== undefined) ? (
|
||||||
|
<div className="flex flex-col gap-1 text-xs pt-1 border-t border-slate-700/50">
|
||||||
|
<div className="text-slate-500 font-semibold uppercase tracking-wide text-[10px]">🏛️ Institutional</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{row.confidence_score !== null && row.confidence_score !== undefined && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-slate-500">Confidence</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex-1 bg-slate-800 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1 rounded-full",
|
||||||
|
(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0) >= 70 ? "bg-green-500" :
|
||||||
|
(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0) >= 50 ? "bg-yellow-500" :
|
||||||
|
"bg-orange-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-bold w-8 text-right",
|
||||||
|
(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0) >= 70 ? 'text-green-400' :
|
||||||
|
(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0) >= 50 ? 'text-yellow-400' :
|
||||||
|
'text-orange-400'
|
||||||
|
)}>
|
||||||
|
{Math.round(typeof row.confidence_score === 'number' ? row.confidence_score : parseFloat(row.confidence_score) || 0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.signal_strength !== null && row.signal_strength !== undefined && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-slate-500">Strength</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex-1 bg-slate-800 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1 rounded-full",
|
||||||
|
(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0) >= 70 ? "bg-purple-500" :
|
||||||
|
(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0) >= 50 ? "bg-blue-500" :
|
||||||
|
"bg-slate-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-bold w-6 text-right",
|
||||||
|
(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0) >= 70 ? 'text-purple-400' :
|
||||||
|
(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0) >= 50 ? 'text-blue-400' :
|
||||||
|
'text-slate-400'
|
||||||
|
)}>
|
||||||
|
{Math.round(typeof row.signal_strength === 'number' ? row.signal_strength : parseFloat(row.signal_strength) || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.relative_premium_score !== null && row.relative_premium_score !== undefined && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-slate-500">Rel. Prem</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex-1 bg-slate-800 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1 rounded-full",
|
||||||
|
(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0) >= 70 ? "bg-green-500" :
|
||||||
|
(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0) >= 50 ? "bg-yellow-500" :
|
||||||
|
"bg-orange-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-bold w-6 text-right",
|
||||||
|
(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0) >= 70 ? 'text-green-400' :
|
||||||
|
(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0) >= 50 ? 'text-yellow-400' :
|
||||||
|
'text-orange-400'
|
||||||
|
)}>
|
||||||
|
{Math.round(typeof row.relative_premium_score === 'number' ? row.relative_premium_score : parseFloat(row.relative_premium_score) || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Phase 3: Dealer & Regime Analytics */}
|
||||||
|
{(row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined) ||
|
||||||
|
row.market_regime || row.volatility_intent ? (
|
||||||
|
<div className="flex flex-col gap-1 text-xs pt-1 border-t border-slate-700/50">
|
||||||
|
<div className="text-slate-500 font-semibold uppercase tracking-wide text-[10px]">🎯 Dealer & Regime</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[10px] text-slate-500">Pressure:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-bold",
|
||||||
|
(typeof row.dealer_hedge_pressure_score === 'number' ? row.dealer_hedge_pressure_score : parseFloat(row.dealer_hedge_pressure_score) || 0) >= 70 ? "text-red-400" :
|
||||||
|
(typeof row.dealer_hedge_pressure_score === 'number' ? row.dealer_hedge_pressure_score : parseFloat(row.dealer_hedge_pressure_score) || 0) >= 50 ? "text-orange-400" :
|
||||||
|
"text-yellow-400"
|
||||||
|
)}>
|
||||||
|
{Math.round(typeof row.dealer_hedge_pressure_score === 'number' ? row.dealer_hedge_pressure_score : parseFloat(row.dealer_hedge_pressure_score) || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.market_regime && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-1.5 py-0",
|
||||||
|
row.market_regime === 'TREND' ? "bg-green-500/20 border-green-500/50 text-green-400" :
|
||||||
|
row.market_regime === 'RANGE' ? "bg-yellow-500/20 border-yellow-500/50 text-yellow-400" :
|
||||||
|
"bg-red-500/20 border-red-500/50 text-red-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.market_regime === 'TREND' ? '📈' : row.market_regime === 'RANGE' ? '↔️' : '⚡'} {row.market_regime}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{row.volatility_intent && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-1.5 py-0",
|
||||||
|
row.volatility_intent === 'LONG_VOL' ? "bg-green-500/20 border-green-500/50 text-green-400" :
|
||||||
|
row.volatility_intent === 'SHORT_VOL' ? "bg-red-500/20 border-red-500/50 text-red-400" :
|
||||||
|
row.volatility_intent === 'DIRECTIONAL' ? "bg-blue-500/20 border-blue-500/50 text-blue-400" :
|
||||||
|
"bg-orange-500/20 border-orange-500/50 text-orange-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.volatility_intent === 'LONG_VOL' ? '📊' : row.volatility_intent === 'SHORT_VOL' ? '📉' : row.volatility_intent === 'DIRECTIONAL' ? '➡️' : '🔄'} {row.volatility_intent.replace('_', '/')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Time-Sequenced Metrics */}
|
||||||
|
{(row.flow_acceleration !== null && row.flow_acceleration !== undefined) ||
|
||||||
|
(row.time_between_hits !== null && row.time_between_hits !== undefined) ||
|
||||||
|
(row.follow_on_ratio !== null && row.follow_on_ratio !== undefined) ||
|
||||||
|
row.strike_laddering_detected ? (
|
||||||
|
<div className="flex flex-col gap-1 text-xs pt-1 border-t border-slate-700/50">
|
||||||
|
<div className="text-slate-500 font-semibold uppercase tracking-wide text-[10px]">⏱️ Time-Sequenced</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1 text-[10px]">
|
||||||
|
{row.flow_acceleration !== null && row.flow_acceleration !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">Accel:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"font-bold",
|
||||||
|
(typeof row.flow_acceleration === 'number' ? row.flow_acceleration : parseFloat(row.flow_acceleration) || 0) > 0 ? "text-green-400" : "text-red-400"
|
||||||
|
)}>
|
||||||
|
{(typeof row.flow_acceleration === 'number' ? row.flow_acceleration : parseFloat(row.flow_acceleration) || 0) >= 1e6
|
||||||
|
? `${((typeof row.flow_acceleration === 'number' ? row.flow_acceleration : parseFloat(row.flow_acceleration) || 0) / 1e6).toFixed(1)}M/min`
|
||||||
|
: `${((typeof row.flow_acceleration === 'number' ? row.flow_acceleration : parseFloat(row.flow_acceleration) || 0) / 1e3).toFixed(0)}K/min`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.time_between_hits !== null && row.time_between_hits !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">Gap:</span>
|
||||||
|
<span className="text-slate-300 font-semibold">
|
||||||
|
{(typeof row.time_between_hits === 'number' ? row.time_between_hits : parseFloat(row.time_between_hits) || 0).toFixed(1)}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.follow_on_ratio !== null && row.follow_on_ratio !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">Follow:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"font-bold",
|
||||||
|
(typeof row.follow_on_ratio === 'number' ? row.follow_on_ratio : parseFloat(row.follow_on_ratio) || 0) >= 0.7 ? "text-green-400" :
|
||||||
|
(typeof row.follow_on_ratio === 'number' ? row.follow_on_ratio : parseFloat(row.follow_on_ratio) || 0) >= 0.4 ? "text-yellow-400" :
|
||||||
|
"text-red-400"
|
||||||
|
)}>
|
||||||
|
{((typeof row.follow_on_ratio === 'number' ? row.follow_on_ratio : parseFloat(row.follow_on_ratio) || 0) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.strike_laddering_detected !== null && row.strike_laddering_detected !== undefined && row.strike_laddering_detected && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">Ladder:</span>
|
||||||
|
<span className="text-green-400 font-bold">✅</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Signal */}
|
{/* Signal */}
|
||||||
{signal && signal.signal !== 'NEUTRAL' && signal.signal !== 'WAIT' && (
|
{signal && signal.signal !== 'NEUTRAL' && signal.signal !== 'WAIT' && (
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { getApiUrl } from '@/config/api';
|
||||||
// Column visibility configuration
|
// Column visibility configuration
|
||||||
const COLUMN_GROUPS = {
|
const COLUMN_GROUPS = {
|
||||||
core: ['Symbol', 'badges', 'signalQuality', 'reversal', 'convergence', 'Rocket', 'Momentum', 'TradeSignal', 'TradePlan', 'NetPremium', 'Premium', 'SignalTier', 'Checklist'],
|
core: ['Symbol', 'badges', 'signalQuality', 'reversal', 'convergence', 'Rocket', 'Momentum', 'TradeSignal', 'TradePlan', 'NetPremium', 'Premium', 'SignalTier', 'Checklist'],
|
||||||
|
institutional: ['confidence_score', 'signal_strength', 'relative_premium_score'], // Phase 2: Institutional Analytics columns
|
||||||
option: ['CallPut', 'Strike', 'Spot', 'Moneyness', 'ExpirationDate'],
|
option: ['CallPut', 'Strike', 'Spot', 'Moneyness', 'ExpirationDate'],
|
||||||
volume: ['Volume', 'OI', 'Price', 'Side'],
|
volume: ['Volume', 'OI', 'Price', 'Side'],
|
||||||
price: ['PctVsPriorClose', 'PctVsRthOpen', 'Pct5m', 'Pct15m', 'PriceReaction5m', 'VWAPDistance'],
|
price: ['PctVsPriorClose', 'PctVsRthOpen', 'Pct5m', 'Pct15m', 'PriceReaction5m', 'VWAPDistance'],
|
||||||
|
|
@ -54,6 +55,7 @@ export default function OptionsFlowPanel() {
|
||||||
const [reversals, setReversals] = useState([]);
|
const [reversals, setReversals] = useState([]);
|
||||||
const [convergences, setConvergences] = useState([]);
|
const [convergences, setConvergences] = useState([]);
|
||||||
const [filterNoise, setFilterNoise] = useState(true); // Filter retail noise by default
|
const [filterNoise, setFilterNoise] = useState(true); // Filter retail noise by default
|
||||||
|
const [cardViewSymbolFilter, setCardViewSymbolFilter] = useState(''); // Client-side symbol filter for card view only
|
||||||
const columnSelectorRef = useRef(null);
|
const columnSelectorRef = useRef(null);
|
||||||
|
|
||||||
const { data, loading, error, filteredCount, refetch } = useOptionsFlow({
|
const { data, loading, error, filteredCount, refetch } = useOptionsFlow({
|
||||||
|
|
@ -78,6 +80,11 @@ export default function OptionsFlowPanel() {
|
||||||
const flowLedToMove = row.flow_led_to_move === true;
|
const flowLedToMove = row.flow_led_to_move === true;
|
||||||
const checklistScore = row.checklist_score || 0;
|
const checklistScore = row.checklist_score || 0;
|
||||||
|
|
||||||
|
// Institutional analytics (Phase 1)
|
||||||
|
const confidenceScore = row.confidence_score || 0;
|
||||||
|
const signalStrength = row.signal_strength || 0;
|
||||||
|
const relativePremium = row.relative_premium_score || 0;
|
||||||
|
|
||||||
let score = 0;
|
let score = 0;
|
||||||
score += rocketScore * 10; // 0-100 points
|
score += rocketScore * 10; // 0-100 points
|
||||||
score += momentum * 0.5; // 0-50 points
|
score += momentum * 0.5; // 0-50 points
|
||||||
|
|
@ -92,6 +99,11 @@ export default function OptionsFlowPanel() {
|
||||||
if (flowLedToMove) score += 15; // Flow led to move bonus
|
if (flowLedToMove) score += 15; // Flow led to move bonus
|
||||||
score += checklistScore * 2; // Checklist score bonus (0-10 points)
|
score += checklistScore * 2; // Checklist score bonus (0-10 points)
|
||||||
|
|
||||||
|
// Institutional analytics bonuses (Phase 1)
|
||||||
|
score += confidenceScore * 0.2; // 0-20 points (confidence_score is 0-100)
|
||||||
|
score += signalStrength * 0.15; // 0-15 points (signal_strength is 0-100)
|
||||||
|
score += relativePremium * 0.1; // 0-10 points (relative_premium_score is 0-100)
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -219,6 +231,24 @@ export default function OptionsFlowPanel() {
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [data, stockPricesData, activeFilters, reversalsBySymbol]);
|
}, [data, stockPricesData, activeFilters, reversalsBySymbol]);
|
||||||
|
|
||||||
|
// Client-side symbol filter for card view only
|
||||||
|
const cardViewFilteredData = useMemo(() => {
|
||||||
|
if (viewMode !== 'cards' || !cardViewSymbolFilter.trim()) {
|
||||||
|
return filteredData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterSymbol = cardViewSymbolFilter.trim().toUpperCase();
|
||||||
|
return filteredData.filter(row => {
|
||||||
|
const symbolNorm = (row.symbol_norm || '').toUpperCase();
|
||||||
|
const symbol = (row.Symbol || '').toUpperCase();
|
||||||
|
const symbolDisplay = (row.symbolDisplay || '').toUpperCase();
|
||||||
|
|
||||||
|
return symbolNorm.includes(filterSymbol) ||
|
||||||
|
symbol.includes(filterSymbol) ||
|
||||||
|
symbolDisplay.includes(filterSymbol);
|
||||||
|
});
|
||||||
|
}, [filteredData, viewMode, cardViewSymbolFilter]);
|
||||||
|
|
||||||
// Get top 5 trades for summary
|
// Get top 5 trades for summary
|
||||||
// Filter: items repeated more than once, more than 2 rockets, highest premium
|
// Filter: items repeated more than once, more than 2 rockets, highest premium
|
||||||
const topTrades = useMemo(() => {
|
const topTrades = useMemo(() => {
|
||||||
|
|
@ -388,17 +418,17 @@ export default function OptionsFlowPanel() {
|
||||||
{stockPrice && stockPrice.currentPrice && (
|
{stockPrice && stockPrice.currentPrice && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono text-xs font-semibold text-slate-300">
|
<span className="font-mono text-xs font-semibold text-slate-300">
|
||||||
${stockPrice.currentPrice.toFixed(2)}
|
${(typeof stockPrice.currentPrice === 'number' ? stockPrice.currentPrice : parseFloat(stockPrice.currentPrice) || 0).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
{stockPrice.changePercent !== undefined && (
|
{stockPrice.changePercent !== undefined && stockPrice.changePercent !== null && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs font-medium",
|
"text-xs font-medium",
|
||||||
stockPrice.changePercent >= 0
|
(typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0) >= 0
|
||||||
? "text-green-400"
|
? "text-green-400"
|
||||||
: "text-red-400"
|
: "text-red-400"
|
||||||
)}>
|
)}>
|
||||||
{stockPrice.changePercent >= 0 ? '+' : ''}
|
{(typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0) >= 0 ? '+' : ''}
|
||||||
{stockPrice.changePercent.toFixed(2)}%
|
{(typeof stockPrice.changePercent === 'number' ? stockPrice.changePercent : parseFloat(stockPrice.changePercent) || 0).toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -513,7 +543,8 @@ export default function OptionsFlowPanel() {
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const rocket = row.original.Rocket || row.original.rocketDisplay || '';
|
const rocket = row.original.Rocket || row.original.rocketDisplay || '';
|
||||||
const score = row.original.rocketScore || row.original.rocket_score || 0;
|
const scoreRaw = row.original.rocketScore || row.original.rocket_score || 0;
|
||||||
|
const score = typeof scoreRaw === 'number' ? scoreRaw : parseFloat(scoreRaw) || 0;
|
||||||
const maxScore = 10; // Assuming max score is 10
|
const maxScore = 10; // Assuming max score is 10
|
||||||
const percentage = Math.min((score / maxScore) * 100, 100);
|
const percentage = Math.min((score / maxScore) * 100, 100);
|
||||||
|
|
||||||
|
|
@ -788,9 +819,10 @@ export default function OptionsFlowPanel() {
|
||||||
// Add temporal explanation
|
// Add temporal explanation
|
||||||
if (trendValue === 'DEAD') {
|
if (trendValue === 'DEAD') {
|
||||||
if (minutesAgo !== null && minutesAgo >= 60) {
|
if (minutesAgo !== null && minutesAgo >= 60) {
|
||||||
const premiumText = premium >= 1000000
|
const numPremium = typeof premium === 'number' ? premium : parseFloat(premium) || 0;
|
||||||
? `$${(premium / 1000000).toFixed(1)}M premium`
|
const premiumText = numPremium >= 1000000
|
||||||
: `$${(premium / 1000).toFixed(0)}K premium`;
|
? `$${(numPremium / 1000000).toFixed(1)}M premium`
|
||||||
|
: `$${(numPremium / 1000).toFixed(0)}K premium`;
|
||||||
config.tooltip = `Initial positioning detected (${premiumText}), but accumulation stopped. Last flow ${minutesAgo}min ago - no continuation detected.`;
|
config.tooltip = `Initial positioning detected (${premiumText}), but accumulation stopped. Last flow ${minutesAgo}min ago - no continuation detected.`;
|
||||||
config.temporalExplanation = `Flow occurred earlier; no continuation in last ${minutesAgo}min`;
|
config.temporalExplanation = `Flow occurred earlier; no continuation in last ${minutesAgo}min`;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1006,9 +1038,11 @@ export default function OptionsFlowPanel() {
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.PctVsPriorClose;
|
const val = row.original.PctVsPriorClose;
|
||||||
if (val === null || val === undefined) return '—';
|
if (val === null || val === undefined) return '—';
|
||||||
|
const numVal = typeof val === 'number' ? val : parseFloat(val);
|
||||||
|
if (isNaN(numVal)) return '—';
|
||||||
return (
|
return (
|
||||||
<span className={`font-medium ${val > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
<span className={`font-medium ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
{val > 0 ? '+' : ''}{val.toFixed(2)}%
|
{numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1020,9 +1054,11 @@ export default function OptionsFlowPanel() {
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.PctVsRthOpen || row.original.pct_vs_rth_open;
|
const val = row.original.PctVsRthOpen || row.original.pct_vs_rth_open;
|
||||||
if (val === null || val === undefined) return '—';
|
if (val === null || val === undefined) return '—';
|
||||||
|
const numVal = typeof val === 'number' ? val : parseFloat(val);
|
||||||
|
if (isNaN(numVal)) return '—';
|
||||||
return (
|
return (
|
||||||
<span className={`font-medium ${val > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
<span className={`font-medium ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
{val > 0 ? '+' : ''}{val.toFixed(2)}%
|
{numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1034,9 +1070,11 @@ export default function OptionsFlowPanel() {
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.Pct5m || row.original.pct_5m_momo;
|
const val = row.original.Pct5m || row.original.pct_5m_momo;
|
||||||
if (val === null || val === undefined) return '—';
|
if (val === null || val === undefined) return '—';
|
||||||
|
const numVal = typeof val === 'number' ? val : parseFloat(val);
|
||||||
|
if (isNaN(numVal)) return '—';
|
||||||
return (
|
return (
|
||||||
<span className={`text-xs ${val > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
<span className={`text-xs ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
{val > 0 ? '+' : ''}{val.toFixed(2)}%
|
{numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1048,9 +1086,11 @@ export default function OptionsFlowPanel() {
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.Pct15m || row.original.pct_15m_momo;
|
const val = row.original.Pct15m || row.original.pct_15m_momo;
|
||||||
if (val === null || val === undefined) return '—';
|
if (val === null || val === undefined) return '—';
|
||||||
|
const numVal = typeof val === 'number' ? val : parseFloat(val);
|
||||||
|
if (isNaN(numVal)) return '—';
|
||||||
return (
|
return (
|
||||||
<span className={`text-xs ${val > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
<span className={`text-xs ${numVal > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
{val > 0 ? '+' : ''}{val.toFixed(2)}%
|
{numVal > 0 ? '+' : ''}{numVal.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1198,6 +1238,118 @@ export default function OptionsFlowPanel() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Phase 2: Institutional Analytics Columns
|
||||||
|
{
|
||||||
|
accessorKey: 'confidence_score',
|
||||||
|
header: 'Confidence',
|
||||||
|
group: 'institutional',
|
||||||
|
accessorFn: (row) => row.confidence_score || 0,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const scoreRaw = row.original.confidence_score;
|
||||||
|
if (scoreRaw === null || scoreRaw === undefined) return '—';
|
||||||
|
const score = typeof scoreRaw === 'number' ? scoreRaw : parseFloat(scoreRaw);
|
||||||
|
if (isNaN(score)) return '—';
|
||||||
|
|
||||||
|
const roundedScore = Math.round(score);
|
||||||
|
const colorClass = score >= 70 ? 'text-green-400' :
|
||||||
|
score >= 50 ? 'text-yellow-400' :
|
||||||
|
'text-orange-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-[90px]">
|
||||||
|
<div className="flex-1 bg-slate-800 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 rounded-full transition-all",
|
||||||
|
score >= 70 ? "bg-green-500" :
|
||||||
|
score >= 50 ? "bg-yellow-500" :
|
||||||
|
"bg-orange-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, score)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={score >= 70 ? 'success' : score >= 50 ? 'warning' : 'secondary'}
|
||||||
|
className={cn("text-xs w-12 justify-center", colorClass)}
|
||||||
|
>
|
||||||
|
{roundedScore}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'signal_strength',
|
||||||
|
header: 'Signal Strength',
|
||||||
|
group: 'institutional',
|
||||||
|
accessorFn: (row) => row.signal_strength || 0,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const strengthRaw = row.original.signal_strength;
|
||||||
|
if (strengthRaw === null || strengthRaw === undefined) return '—';
|
||||||
|
const strength = typeof strengthRaw === 'number' ? strengthRaw : parseFloat(strengthRaw);
|
||||||
|
if (isNaN(strength)) return '—';
|
||||||
|
|
||||||
|
const roundedStrength = Math.round(strength);
|
||||||
|
const colorClass = strength >= 70 ? 'text-purple-400' :
|
||||||
|
strength >= 50 ? 'text-blue-400' :
|
||||||
|
'text-slate-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-[90px]">
|
||||||
|
<div className="flex-1 bg-slate-800 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 rounded-full transition-all",
|
||||||
|
strength >= 70 ? "bg-purple-500" :
|
||||||
|
strength >= 50 ? "bg-blue-500" :
|
||||||
|
"bg-slate-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, strength)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-xs font-semibold w-8 text-right", colorClass)}>
|
||||||
|
{roundedStrength}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'relative_premium_score',
|
||||||
|
header: 'Rel. Premium',
|
||||||
|
group: 'institutional',
|
||||||
|
accessorFn: (row) => row.relative_premium_score || 0,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const scoreRaw = row.original.relative_premium_score;
|
||||||
|
if (scoreRaw === null || scoreRaw === undefined) return '—';
|
||||||
|
const score = typeof scoreRaw === 'number' ? scoreRaw : parseFloat(scoreRaw);
|
||||||
|
if (isNaN(score)) return '—';
|
||||||
|
|
||||||
|
const roundedScore = Math.round(score);
|
||||||
|
const colorClass = score >= 70 ? 'text-green-400' :
|
||||||
|
score >= 50 ? 'text-yellow-400' :
|
||||||
|
'text-orange-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-[85px]">
|
||||||
|
<div className="flex-1 bg-slate-800 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 rounded-full transition-all",
|
||||||
|
score >= 70 ? "bg-green-500" :
|
||||||
|
score >= 50 ? "bg-yellow-500" :
|
||||||
|
"bg-orange-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, score)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-xs font-semibold w-8 text-right", colorClass)}>
|
||||||
|
{roundedScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
// Phase 1: Price Reaction 5m
|
// Phase 1: Price Reaction 5m
|
||||||
{
|
{
|
||||||
accessorKey: 'PriceReaction5m',
|
accessorKey: 'PriceReaction5m',
|
||||||
|
|
@ -1208,11 +1360,13 @@ export default function OptionsFlowPanel() {
|
||||||
const ledToMove = row.original.flow_led_to_move;
|
const ledToMove = row.original.flow_led_to_move;
|
||||||
|
|
||||||
if (reaction === null || reaction === undefined) return '—';
|
if (reaction === null || reaction === undefined) return '—';
|
||||||
|
const numReaction = typeof reaction === 'number' ? reaction : parseFloat(reaction);
|
||||||
|
if (isNaN(numReaction)) return '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className={`text-xs font-medium ${reaction > 0 ? 'text-green-400' : reaction < 0 ? 'text-red-400' : 'text-slate-400'}`}>
|
<span className={`text-xs font-medium ${numReaction > 0 ? 'text-green-400' : numReaction < 0 ? 'text-red-400' : 'text-slate-400'}`}>
|
||||||
{reaction > 0 ? '+' : ''}{reaction.toFixed(2)}%
|
{numReaction > 0 ? '+' : ''}{numReaction.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
{ledToMove && (
|
{ledToMove && (
|
||||||
<span className="text-xs text-green-400" title="Flow led to price move">✓</span>
|
<span className="text-xs text-green-400" title="Flow led to price move">✓</span>
|
||||||
|
|
@ -1234,22 +1388,26 @@ export default function OptionsFlowPanel() {
|
||||||
if (!vwap) return <span className="text-xs text-slate-500">No VWAP</span>;
|
if (!vwap) return <span className="text-xs text-slate-500">No VWAP</span>;
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
const numVwapDist = typeof vwapDist === 'number' ? vwapDist : parseFloat(vwapDist);
|
||||||
|
if (isNaN(numVwapDist)) return '—';
|
||||||
|
|
||||||
// Color code: near VWAP = good, far from VWAP = caution
|
// Color code: near VWAP = good, far from VWAP = caution
|
||||||
const absDist = Math.abs(vwapDist);
|
const absDist = Math.abs(numVwapDist);
|
||||||
let colorClass = 'text-slate-400';
|
let colorClass = 'text-slate-400';
|
||||||
if (absDist <= 1) colorClass = 'text-green-400';
|
if (absDist <= 1) colorClass = 'text-green-400';
|
||||||
else if (absDist <= 2) colorClass = 'text-yellow-400';
|
else if (absDist <= 2) colorClass = 'text-yellow-400';
|
||||||
else colorClass = 'text-red-400';
|
else colorClass = 'text-red-400';
|
||||||
|
|
||||||
|
const numVwap = typeof vwap === 'number' ? vwap : parseFloat(vwap);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className={`text-xs font-medium ${colorClass}`}>
|
<span className={`text-xs font-medium ${colorClass}`}>
|
||||||
{vwapDist > 0 ? '+' : ''}{vwapDist.toFixed(2)}%
|
{numVwapDist > 0 ? '+' : ''}{numVwapDist.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
{vwap && (
|
{vwap && !isNaN(numVwap) && (
|
||||||
<span className="text-xs text-slate-500" title={`VWAP: $${vwap.toFixed(2)}`}>
|
<span className="text-xs text-slate-500" title={`VWAP: $${numVwap.toFixed(2)}`}>
|
||||||
V: ${vwap.toFixed(2)}
|
V: ${numVwap.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1541,6 +1699,29 @@ export default function OptionsFlowPanel() {
|
||||||
|
|
||||||
{/* View Mode Toggle & Column Selector */}
|
{/* View Mode Toggle & Column Selector */}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{/* Symbol Filter for Card View */}
|
||||||
|
{viewMode === 'cards' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-400 whitespace-nowrap">Symbol:</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by symbol..."
|
||||||
|
value={cardViewSymbolFilter}
|
||||||
|
onChange={(e) => setCardViewSymbolFilter(e.target.value)}
|
||||||
|
className="w-32 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
{cardViewSymbolFilter && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setCardViewSymbolFilter('')}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 bg-slate-800/50 rounded-lg p-1">
|
<div className="flex items-center gap-2 bg-slate-800/50 rounded-lg p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('cards')}
|
onClick={() => setViewMode('cards')}
|
||||||
|
|
@ -1671,7 +1852,7 @@ export default function OptionsFlowPanel() {
|
||||||
{viewMode === 'cards' ? (
|
{viewMode === 'cards' ? (
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/50 rounded-lg shadow-lg p-4">
|
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/50 rounded-lg shadow-lg p-4">
|
||||||
<OptionsFlowCardList
|
<OptionsFlowCardList
|
||||||
data={filteredData}
|
data={cardViewFilteredData}
|
||||||
onCardClick={handleRowClick}
|
onCardClick={handleRowClick}
|
||||||
selectedRow={selectedRow}
|
selectedRow={selectedRow}
|
||||||
reversalsBySymbol={reversalsBySymbol}
|
reversalsBySymbol={reversalsBySymbol}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,642 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3010';
|
||||||
|
|
||||||
|
function fmt(n, decimals = 2, prefix = '') {
|
||||||
|
if (n == null || isNaN(n)) return '—';
|
||||||
|
return `${prefix}${n.toFixed(decimals)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
const val = (n * 100).toFixed(1);
|
||||||
|
return `${val}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtLarge(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (Math.abs(n) >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
|
||||||
|
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
|
||||||
|
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
|
||||||
|
if (Math.abs(n) >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
|
||||||
|
return `$${n.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricRow({ label, value, highlight }) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-slate-800/50 hover:bg-slate-800/20 transition-colors">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">{label}</td>
|
||||||
|
<td className={`py-2 px-3 text-sm font-medium text-right ${
|
||||||
|
highlight === 'positive' ? 'text-emerald-400' :
|
||||||
|
highlight === 'negative' ? 'text-red-400' :
|
||||||
|
'text-white'
|
||||||
|
}`}>{value}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SentimentBadge({ sentiment }) {
|
||||||
|
const styles = {
|
||||||
|
BULLISH: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||||
|
BEARISH: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
|
NEUTRAL: 'bg-slate-700/50 text-slate-400 border-slate-600',
|
||||||
|
};
|
||||||
|
const icons = { BULLISH: '▲', BEARISH: '▼', NEUTRAL: '●' };
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${styles[sentiment] || styles.NEUTRAL}`}>
|
||||||
|
{icons[sentiment]} {sentiment}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectionArrow({ dir }) {
|
||||||
|
if (dir === 'INCREASED') return <span className="text-emerald-400 font-bold">▲</span>;
|
||||||
|
if (dir === 'DECREASED') return <span className="text-red-400 font-bold">▼</span>;
|
||||||
|
return <span className="text-slate-500">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StockDetailPanel({ symbol, onClose }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
const [stockData, setStockData] = useState(null);
|
||||||
|
const [news, setNews] = useState([]);
|
||||||
|
const [holders, setHolders] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newsLoading, setNewsLoading] = useState(false);
|
||||||
|
const [holdersLoading, setHoldersLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!symbol) return;
|
||||||
|
setLoading(true);
|
||||||
|
setStockData(null);
|
||||||
|
setNews([]);
|
||||||
|
setHolders(null);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/api/market/stock/${symbol}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (d.success) setStockData(d.data); })
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [symbol]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!symbol || activeTab !== 'news') return;
|
||||||
|
if (news.length > 0) return;
|
||||||
|
setNewsLoading(true);
|
||||||
|
fetch(`${API_BASE}/api/market/stock/${symbol}/news`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (d.success) setNews(d.data || []); })
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setNewsLoading(false));
|
||||||
|
}, [symbol, activeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!symbol || activeTab !== 'holders') return;
|
||||||
|
if (holders) return;
|
||||||
|
setHoldersLoading(true);
|
||||||
|
fetch(`${API_BASE}/api/market/stock/${symbol}/holders`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (d.success) setHolders(d.data); })
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setHoldersLoading(false));
|
||||||
|
}, [symbol, activeTab]);
|
||||||
|
|
||||||
|
if (!symbol) return null;
|
||||||
|
|
||||||
|
const q = stockData?.quote;
|
||||||
|
const f = stockData?.fundamentals;
|
||||||
|
const t = stockData?.technicals;
|
||||||
|
const meta = stockData?.meta;
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'overview', label: '📋 Overview' },
|
||||||
|
{ id: 'fundamentals', label: '📊 Fundamentals' },
|
||||||
|
{ id: 'technicals', label: '📈 Technicals' },
|
||||||
|
{ id: 'holders', label: '🏛️ Institutional' },
|
||||||
|
{ id: 'news', label: '📰 News' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/50 bg-slate-950/40">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">{symbol}</h2>
|
||||||
|
{stockData?.capTier && (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
|
||||||
|
stockData.capTier === 'LARGE' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
|
||||||
|
stockData.capTier === 'MID' ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
|
||||||
|
stockData.capTier === 'ETF' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||||
|
'bg-orange-500/20 text-orange-400 border-orange-500/30'
|
||||||
|
}`}>{stockData.capTier} CAP</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400 text-sm">{meta?.name} · {meta?.sector}</div>
|
||||||
|
</div>
|
||||||
|
{q && (
|
||||||
|
<div className="ml-4">
|
||||||
|
<div className="text-2xl font-bold text-white tabular-nums">
|
||||||
|
${q.price?.toFixed(2) ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm font-medium ${
|
||||||
|
(q.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) : '—'}
|
||||||
|
{' '}({q.change_pct != null ? (q.change_pct >= 0 ? '+' : '') + q.change_pct.toFixed(2) + '%' : '—'})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-white transition-colors text-2xl font-light w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-slate-700/50 bg-slate-950/20">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium transition-all ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-b-2 border-blue-500 text-blue-400 bg-blue-500/5'
|
||||||
|
: 'text-slate-500 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-5 overflow-y-auto max-h-[calc(100vh-300px)]">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3 animate-pulse">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-10 bg-slate-800 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeTab === 'overview' && <OverviewTab q={q} f={f} t={t} />}
|
||||||
|
{activeTab === 'fundamentals' && <FundamentalsTab f={f} />}
|
||||||
|
{activeTab === 'technicals' && <TechnicalsTab t={t} q={q} />}
|
||||||
|
{activeTab === 'holders' && <HoldersTab data={holders} loading={holdersLoading} />}
|
||||||
|
{activeTab === 'news' && <NewsTab items={news} loading={newsLoading} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTab({ q, f, t }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Price Snapshot</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
<MetricRow label="Open" value={q?.open != null ? `$${q.open.toFixed(2)}` : '—'} />
|
||||||
|
<MetricRow label="Day High" value={q?.high != null ? `$${q.high.toFixed(2)}` : '—'} />
|
||||||
|
<MetricRow label="Day Low" value={q?.low != null ? `$${q.low.toFixed(2)}` : '—'} />
|
||||||
|
<MetricRow label="Prev Close" value={q?.prev_close != null ? `$${q.prev_close.toFixed(2)}` : '—'} />
|
||||||
|
<MetricRow label="52W High" value={f?.week52_high != null ? `$${f.week52_high.toFixed(2)}` : '—'} />
|
||||||
|
<MetricRow label="52W Low" value={f?.week52_low != null ? `$${f.week52_low.toFixed(2)}` : '—'} />
|
||||||
|
<MetricRow label="Volume" value={q?.volume != null ? q.volume.toLocaleString() : '—'} />
|
||||||
|
<MetricRow label="Avg Vol (3M)" value={f?.avg_volume_3m != null ? f.avg_volume_3m.toLocaleString() : '—'} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Key Metrics</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
<MetricRow label="P/E (TTM)" value={fmt(f?.pe_ttm)} />
|
||||||
|
<MetricRow label="P/E (Fwd)" value={fmt(f?.pe_forward)} />
|
||||||
|
<MetricRow label="EPS (TTM)" value={f?.eps_ttm != null ? `$${f.eps_ttm.toFixed(2)}` : '—'} />
|
||||||
|
<MetricRow label="Beta" value={fmt(f?.beta)} />
|
||||||
|
<MetricRow label="Short % Float" value={fmtPct(f?.short_pct_float)} highlight={f?.short_pct_float > 0.15 ? 'negative' : undefined} />
|
||||||
|
<MetricRow label="Inst. Ownership" value={fmtPct(f?.held_pct_institutions)} />
|
||||||
|
<MetricRow label="Analyst Target" value={f?.target_mean != null ? `$${f.target_mean.toFixed(2)}` : '—'} />
|
||||||
|
<MetricRow label="Recommendation" value={f?.recommendation?.toUpperCase() ?? '—'}
|
||||||
|
highlight={f?.recommendation === 'buy' || f?.recommendation === 'strongBuy' ? 'positive' :
|
||||||
|
f?.recommendation === 'sell' || f?.recommendation === 'strongSell' ? 'negative' : undefined}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FundamentalsTab({ f }) {
|
||||||
|
if (!f) return <div className="text-slate-500 text-center py-8">No fundamental data available</div>;
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: '💰 Valuation',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'P/E Ratio (TTM)', value: fmt(f.pe_ttm, 1) },
|
||||||
|
{ label: 'P/E Ratio (Fwd)', value: fmt(f.pe_forward, 1) },
|
||||||
|
{ label: 'Price / Book', value: fmt(f.price_to_book, 2) },
|
||||||
|
{ label: 'Price / Sales', value: fmt(f.price_to_sales, 2) },
|
||||||
|
{ label: 'EV / EBITDA', value: fmt(f.ev_ebitda, 1) },
|
||||||
|
{ label: 'Market Cap', value: fmtLarge(f.market_cap) },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '📈 Growth',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'EPS (TTM)', value: f.eps_ttm != null ? `$${f.eps_ttm.toFixed(2)}` : '—' },
|
||||||
|
{ label: 'EPS (Forward)', value: f.eps_forward != null ? `$${f.eps_forward.toFixed(2)}` : '—' },
|
||||||
|
{ label: 'Earnings Growth', value: fmtPct(f.earnings_growth), h: f.earnings_growth > 0 ? 'positive' : f.earnings_growth < 0 ? 'negative' : undefined },
|
||||||
|
{ label: 'Revenue Growth', value: fmtPct(f.revenue_growth), h: f.revenue_growth > 0 ? 'positive' : f.revenue_growth < 0 ? 'negative' : undefined },
|
||||||
|
{ label: 'Total Revenue', value: fmtLarge(f.total_revenue) },
|
||||||
|
{ label: 'Free Cash Flow', value: fmtLarge(f.free_cash_flow), h: f.free_cash_flow > 0 ? 'positive' : f.free_cash_flow < 0 ? 'negative' : undefined },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '📊 Margins',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Gross Margin', value: fmtPct(f.gross_margins) },
|
||||||
|
{ label: 'EBITDA Margin', value: fmtPct(f.ebitda_margins) },
|
||||||
|
{ label: 'Operating Margin', value: fmtPct(f.operating_margins) },
|
||||||
|
{ label: 'Profit Margin', value: fmtPct(f.profit_margins) },
|
||||||
|
{ label: 'Return on Equity', value: fmtPct(f.return_on_equity), h: f.return_on_equity > 0.15 ? 'positive' : f.return_on_equity < 0 ? 'negative' : undefined },
|
||||||
|
{ label: 'Return on Assets', value: fmtPct(f.return_on_assets) },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '🏦 Balance Sheet',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Debt / Equity', value: fmt(f.debt_to_equity, 2), h: f.debt_to_equity > 2 ? 'negative' : f.debt_to_equity < 0.5 ? 'positive' : undefined },
|
||||||
|
{ label: 'Current Ratio', value: fmt(f.current_ratio, 2), h: f.current_ratio > 2 ? 'positive' : f.current_ratio < 1 ? 'negative' : undefined },
|
||||||
|
{ label: 'Quick Ratio', value: fmt(f.quick_ratio, 2) },
|
||||||
|
{ label: 'Total Debt', value: fmtLarge(f.total_debt) },
|
||||||
|
{ label: 'Book Value / Share', value: f.book_value != null ? `$${f.book_value.toFixed(2)}` : '—' },
|
||||||
|
{ label: 'Float Shares', value: f.float_shares != null ? (f.float_shares / 1e6).toFixed(1) + 'M' : '—' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '🎯 Analyst Consensus',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Recommendation', value: f.recommendation?.toUpperCase() ?? '—', h: f.recommendation?.includes('buy') ? 'positive' : f.recommendation?.includes('sell') ? 'negative' : undefined },
|
||||||
|
{ label: 'Target (Mean)', value: f.target_mean != null ? `$${f.target_mean.toFixed(2)}` : '—' },
|
||||||
|
{ label: 'Target (High)', value: f.target_high != null ? `$${f.target_high.toFixed(2)}` : '—' },
|
||||||
|
{ label: 'Target (Low)', value: f.target_low != null ? `$${f.target_low.toFixed(2)}` : '—' },
|
||||||
|
{ label: '# Analysts', value: f.analyst_count ?? '—' },
|
||||||
|
{ label: 'Strong Buy / Buy', value: `${f.rec_strong_buy ?? 0} / ${f.rec_buy ?? 0}`, h: 'positive' },
|
||||||
|
{ label: 'Hold', value: `${f.rec_hold ?? 0}` },
|
||||||
|
{ label: 'Sell / Strong Sell', value: `${f.rec_sell ?? 0} / ${f.rec_strong_sell ?? 0}`, h: (f.rec_sell + f.rec_strong_sell) > 0 ? 'negative' : undefined },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '📉 Short Interest',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Short % of Float', value: fmtPct(f.short_pct_float), h: f.short_pct_float > 0.20 ? 'negative' : f.short_pct_float < 0.05 ? 'positive' : undefined },
|
||||||
|
{ label: 'Shares Short', value: f.shares_short != null ? (f.shares_short / 1e6).toFixed(1) + 'M' : '—' },
|
||||||
|
{ label: 'Inst. Ownership', value: fmtPct(f.held_pct_institutions) },
|
||||||
|
{ label: 'Insider Ownership', value: fmtPct(f.held_pct_insiders) },
|
||||||
|
{ label: 'Dividend Yield', value: fmtPct(f.dividend_yield), h: f.dividend_yield > 0 ? 'positive' : undefined },
|
||||||
|
{ label: 'Payout Ratio', value: fmtPct(f.payout_ratio) },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-6">
|
||||||
|
{sections.map(section => (
|
||||||
|
<div key={section.title}>
|
||||||
|
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">{section.title}</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{section.metrics.map(m => (
|
||||||
|
<MetricRow key={m.label} label={m.label} value={m.value} highlight={m.h} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechnicalsTab({ t, q }) {
|
||||||
|
if (!t) return <div className="text-slate-500 text-center py-8">Insufficient price history for technical calculations</div>;
|
||||||
|
|
||||||
|
function rsiColor(v) {
|
||||||
|
if (v == null) return 'text-white';
|
||||||
|
if (v >= 70) return 'text-red-400';
|
||||||
|
if (v <= 30) return 'text-emerald-400';
|
||||||
|
return 'text-white';
|
||||||
|
}
|
||||||
|
function rsiLabel(v) {
|
||||||
|
if (v == null) return '';
|
||||||
|
if (v >= 70) return ' (Overbought)';
|
||||||
|
if (v <= 30) return ' (Oversold)';
|
||||||
|
return ' (Neutral)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Momentum Indicators</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-slate-800/50">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">RSI (14)</td>
|
||||||
|
<td className={`py-2 px-3 text-sm font-bold text-right ${rsiColor(t.rsi_14)}`}>
|
||||||
|
{t.rsi_14 != null ? `${t.rsi_14.toFixed(1)}${rsiLabel(t.rsi_14)}` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-800/50">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">MACD Line</td>
|
||||||
|
<td className={`py-2 px-3 text-sm font-medium text-right ${t.macd_line >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{t.macd_line != null ? t.macd_line.toFixed(4) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-800/50">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">MACD Signal</td>
|
||||||
|
<td className="py-2 px-3 text-sm font-medium text-right text-slate-300">
|
||||||
|
{t.macd_signal != null ? t.macd_signal.toFixed(4) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-800/50">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">MACD Histogram</td>
|
||||||
|
<td className={`py-2 px-3 text-sm font-medium text-right ${t.macd_histogram >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{t.macd_histogram != null ? t.macd_histogram.toFixed(4) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Moving Averages</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-slate-800/50">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">SMA 50</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-right">
|
||||||
|
<span className="text-white">{t.sma_50 != null ? `$${t.sma_50.toFixed(2)}` : '—'}</span>
|
||||||
|
{t.above_sma50 != null && (
|
||||||
|
<span className={`ml-2 text-xs ${t.above_sma50 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{t.above_sma50 ? '▲ Above' : '▼ Below'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-800/50">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">SMA 200</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-right">
|
||||||
|
<span className="text-white">{t.sma_200 != null ? `$${t.sma_200.toFixed(2)}` : '—'}</span>
|
||||||
|
{t.above_sma200 != null && (
|
||||||
|
<span className={`ml-2 text-xs ${t.above_sma200 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{t.above_sma200 ? '▲ Above' : '▼ Below'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-800/50">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">% from SMA 50</td>
|
||||||
|
<td className={`py-2 px-3 text-sm font-medium text-right ${t.pct_from_sma50 >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{t.pct_from_sma50 != null ? `${t.pct_from_sma50.toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-800/50">
|
||||||
|
<td className="py-2 px-3 text-slate-400 text-sm">% from SMA 200</td>
|
||||||
|
<td className={`py-2 px-3 text-sm font-medium text-right ${t.pct_from_sma200 >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{t.pct_from_sma200 != null ? `${t.pct_from_sma200.toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-4 p-3 rounded-lg bg-slate-800/50 border border-slate-700/30">
|
||||||
|
<div className="text-xs text-slate-500 mb-1 font-semibold uppercase tracking-wide">Signal Summary</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{t.rsi_14 != null && (
|
||||||
|
<span className={`text-xs px-2 py-1 rounded border ${
|
||||||
|
t.rsi_14 >= 70 ? 'bg-red-500/20 text-red-400 border-red-500/30' :
|
||||||
|
t.rsi_14 <= 30 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||||
|
'bg-slate-700 text-slate-400 border-slate-600'
|
||||||
|
}`}>
|
||||||
|
RSI {t.rsi_14 >= 70 ? '🔴 OS' : t.rsi_14 <= 30 ? '🟢 OB' : '⚪ Neutral'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.macd_histogram != null && (
|
||||||
|
<span className={`text-xs px-2 py-1 rounded border ${
|
||||||
|
t.macd_histogram > 0 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||||
|
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
MACD {t.macd_histogram > 0 ? '🟢 Bull' : '🔴 Bear'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.above_sma50 != null && (
|
||||||
|
<span className={`text-xs px-2 py-1 rounded border ${
|
||||||
|
t.above_sma50 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||||
|
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{t.above_sma50 ? '🟢 Above SMA50' : '🔴 Below SMA50'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.above_sma200 != null && (
|
||||||
|
<span className={`text-xs px-2 py-1 rounded border ${
|
||||||
|
t.above_sma200 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||||
|
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{t.above_sma200 ? '🟢 Above SMA200' : '🔴 Below SMA200'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoldersTab({ data, loading }) {
|
||||||
|
if (loading) return <div className="space-y-3 animate-pulse">{Array.from({ length: 5 }).map((_, i) => <div key={i} className="h-10 bg-slate-800 rounded" />)}</div>;
|
||||||
|
if (!data) return <div className="text-slate-500 text-center py-8">Loading holder data...</div>;
|
||||||
|
|
||||||
|
const inst = data.institutional;
|
||||||
|
const ins = data.insiders;
|
||||||
|
|
||||||
|
function fmtLargeLocal(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
|
||||||
|
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
|
||||||
|
if (Math.abs(n) >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
|
||||||
|
return `$${n.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Institutional summary */}
|
||||||
|
{inst?.summary && (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Inst. Ownership', value: inst.summary.pct_held_institutions != null ? `${(inst.summary.pct_held_institutions * 100).toFixed(1)}%` : '—' },
|
||||||
|
{ label: 'Float Inst. %', value: inst.summary.pct_float_institutions != null ? `${(inst.summary.pct_float_institutions * 100).toFixed(1)}%` : '—' },
|
||||||
|
{ label: 'Insider %', value: inst.summary.pct_held_insiders != null ? `${(inst.summary.pct_held_insiders * 100).toFixed(1)}%` : '—' },
|
||||||
|
{ label: 'Inst. Count', value: inst.summary.institution_count?.toLocaleString() ?? '—' },
|
||||||
|
].map(m => (
|
||||||
|
<div key={m.label} className="bg-slate-800/50 rounded-lg p-3 border border-slate-700/30">
|
||||||
|
<div className="text-xs text-slate-500">{m.label}</div>
|
||||||
|
<div className="text-lg font-bold text-white mt-1">{m.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top institutional holders */}
|
||||||
|
{inst?.holders && inst.holders.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">🏛️ Top Institutional Holders</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-500 text-xs bg-slate-950/30">
|
||||||
|
<th className="text-left px-3 py-2">Institution</th>
|
||||||
|
<th className="text-right px-3 py-2">% Held</th>
|
||||||
|
<th className="text-right px-3 py-2">Shares</th>
|
||||||
|
<th className="text-right px-3 py-2">Value</th>
|
||||||
|
<th className="text-center px-3 py-2">QoQ Change</th>
|
||||||
|
<th className="text-right px-3 py-2">Reported</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
|
{inst.holders.slice(0, 10).map((h, i) => (
|
||||||
|
<tr key={i} className="hover:bg-slate-800/20">
|
||||||
|
<td className="px-3 py-2.5 text-white font-medium">{h.name}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-slate-300 tabular-nums">
|
||||||
|
{h.pct_held != null ? `${(h.pct_held * 100).toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-slate-400 tabular-nums">
|
||||||
|
{h.shares != null ? h.shares.toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-slate-400 tabular-nums">
|
||||||
|
{h.value != null ? fmtLargeLocal(h.value) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
<DirectionArrow dir={h.direction} />
|
||||||
|
{h.shares_change != null && (
|
||||||
|
<span className={`ml-1 text-xs ${h.shares_change > 0 ? 'text-emerald-400' : h.shares_change < 0 ? 'text-red-400' : 'text-slate-500'}`}>
|
||||||
|
{h.shares_change > 0 ? '+' : ''}{h.shares_change?.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-slate-500 text-xs">{h.date_reported || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Insider activity */}
|
||||||
|
{ins && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="text-xs uppercase tracking-widest text-slate-500 font-semibold">👤 Insider Activity (90d)</div>
|
||||||
|
{ins.insider_summary && (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
|
||||||
|
ins.insider_summary.net_sentiment === 'BULLISH' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||||
|
ins.insider_summary.net_sentiment === 'BEARISH' ? 'bg-red-500/20 text-red-400 border-red-500/30' :
|
||||||
|
'bg-slate-700 text-slate-400 border-slate-600'
|
||||||
|
}`}>
|
||||||
|
{ins.insider_summary.net_sentiment} · {ins.insider_summary.buys_90d}B / {ins.insider_summary.sells_90d}S
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ins.transactions && ins.transactions.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-500 text-xs bg-slate-950/30">
|
||||||
|
<th className="text-left px-3 py-2">Insider</th>
|
||||||
|
<th className="text-left px-3 py-2">Title</th>
|
||||||
|
<th className="text-center px-3 py-2">Type</th>
|
||||||
|
<th className="text-right px-3 py-2">Shares</th>
|
||||||
|
<th className="text-right px-3 py-2">Value</th>
|
||||||
|
<th className="text-right px-3 py-2">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
|
{ins.transactions.slice(0, 15).map((tx, i) => (
|
||||||
|
<tr key={i} className="hover:bg-slate-800/20">
|
||||||
|
<td className="px-3 py-2 text-white font-medium">{tx.insider_name}</td>
|
||||||
|
<td className="px-3 py-2 text-slate-400 text-xs">{tx.title}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||||
|
tx.direction === 'BUY' ? 'bg-emerald-500/20 text-emerald-400' :
|
||||||
|
tx.direction === 'SELL' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
'bg-slate-700 text-slate-400'
|
||||||
|
}`}>{tx.direction}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-300 tabular-nums">
|
||||||
|
{tx.shares != null ? tx.shares.toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-400 tabular-nums">
|
||||||
|
{tx.value != null ? fmtLargeLocal(tx.value) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-500 text-xs">{tx.start_date || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-500 text-sm py-4 text-center">No recent insider transactions</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewsTab({ items, loading }) {
|
||||||
|
if (loading) return <div className="space-y-3 animate-pulse">{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-16 bg-slate-800 rounded" />)}</div>;
|
||||||
|
if (!items || items.length === 0) return <div className="text-slate-500 text-center py-8">No financial news found for this symbol</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block p-3 rounded-lg bg-slate-800/40 border border-slate-700/30 hover:bg-slate-800/70 hover:border-slate-600/50 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-white text-sm font-medium group-hover:text-blue-400 transition-colors line-clamp-2">
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
{item.snippet && (
|
||||||
|
<div className="text-slate-500 text-xs mt-1 line-clamp-1">{item.snippet}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<span className="text-xs text-slate-600">{item.source}</span>
|
||||||
|
{item.publishedAt && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-slate-700">·</span>
|
||||||
|
<span className="text-xs text-slate-600">
|
||||||
|
{new Date(item.publishedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SentimentBadge sentiment={item.sentiment} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge } from '../ui/Badge';
|
import { Badge } from '../ui/Badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { useAIAnalysis } from '@/hooks/useAIAnalysis';
|
|
||||||
import { Phase1EligibilityModal } from './Phase1EligibilityModal';
|
import { Phase1EligibilityModal } from './Phase1EligibilityModal';
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { FlowDetailsSection } from './expandedRowDetails/FlowDetailsSection';
|
||||||
|
import { InstitutionalAnalyticsSection } from './expandedRowDetails/InstitutionalAnalyticsSection';
|
||||||
|
import { DealerRegimeSection } from './expandedRowDetails/DealerRegimeSection';
|
||||||
|
import { TimeSequencedSection } from './expandedRowDetails/TimeSequencedSection';
|
||||||
|
import { PriceContextSection } from './expandedRowDetails/PriceContextSection';
|
||||||
|
import { AnalysisSection } from './expandedRowDetails/AnalysisSection';
|
||||||
|
import { VolumeHistorySection } from './expandedRowDetails/VolumeHistorySection';
|
||||||
|
|
||||||
export function ExpandedRowDetails({ row }) {
|
export function ExpandedRowDetails({ row }) {
|
||||||
const [showAIAnalysis, setShowAIAnalysis] = useState(false);
|
|
||||||
const [showPhase1Modal, setShowPhase1Modal] = useState(false);
|
const [showPhase1Modal, setShowPhase1Modal] = useState(false);
|
||||||
const { loading: aiLoading, error: aiError, analysis: aiAnalysis, fetchAnalysis } = useAIAnalysis();
|
|
||||||
|
|
||||||
// Check if Phase 1 data exists
|
// Check if Phase 1 data exists
|
||||||
const hasPhase1Data = row.signal_tier !== undefined || row.checklist_passed !== undefined || row.flow_led_to_move !== undefined;
|
const hasPhase1Data = row.signal_tier !== undefined || row.checklist_passed !== undefined || row.flow_led_to_move !== undefined;
|
||||||
|
|
||||||
const signal = row.tradeSignal;
|
const signal = row.tradeSignal;
|
||||||
const score = row.rocketScore || row.rocket_score || 0;
|
|
||||||
const netPremium = (row.bull_total || 0) - (row.bear_total || 0);
|
const netPremium = (row.bull_total || 0) - (row.bear_total || 0);
|
||||||
const bullTotal = row.bull_total || 0;
|
const bullTotal = row.bull_total || 0;
|
||||||
const bearTotal = row.bear_total || 0;
|
const bearTotal = row.bear_total || 0;
|
||||||
const premium = row.premium_num || 0;
|
|
||||||
const volume = row.Volume || row.vol_num || 0;
|
const volume = row.Volume || row.vol_num || 0;
|
||||||
const oi = row.OI || row.oi_num || 0;
|
const oi = row.OI || row.oi_num || 0;
|
||||||
const currentPriceRaw = row.u_close || row.spot_num || row.Price || 0;
|
const currentPriceRaw = row.u_close || row.spot_num || row.Price || 0;
|
||||||
|
|
@ -42,8 +42,6 @@ export function ExpandedRowDetails({ row }) {
|
||||||
const expirationDate = row.ExpirationDate;
|
const expirationDate = row.ExpirationDate;
|
||||||
|
|
||||||
// Additional important details
|
// Additional important details
|
||||||
const side = row.Side || '—';
|
|
||||||
const price = row.Price || 0;
|
|
||||||
const spot = row.Spot || row.u_close || row.spot_num || 0;
|
const spot = row.Spot || row.u_close || row.spot_num || 0;
|
||||||
const moneynessPct = row.moneyness_pct || 0;
|
const moneynessPct = row.moneyness_pct || 0;
|
||||||
const lastFlowMinutesAgo = trend?.lastFlowMinutesAgo ?? decay?.lastFlowMinutesAgo;
|
const lastFlowMinutesAgo = trend?.lastFlowMinutesAgo ?? decay?.lastFlowMinutesAgo;
|
||||||
|
|
@ -106,9 +104,8 @@ export function ExpandedRowDetails({ row }) {
|
||||||
const formatExpirationDate = (date) => {
|
const formatExpirationDate = (date) => {
|
||||||
if (!date) return 'N/A';
|
if (!date) return 'N/A';
|
||||||
try {
|
try {
|
||||||
// Handle both ISO format and M/D/YYYY format
|
|
||||||
if (typeof date === 'string' && date.includes('/')) {
|
if (typeof date === 'string' && date.includes('/')) {
|
||||||
return date; // Already in M/D/YYYY format
|
return date;
|
||||||
}
|
}
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
|
|
@ -145,211 +142,46 @@ export function ExpandedRowDetails({ row }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-800/30 border-t border-slate-700 p-6">
|
<div className="bg-slate-800/30 border-t border-slate-700 p-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Flow Details */}
|
{/* Flow Details */}
|
||||||
<div className="space-y-3">
|
<FlowDetailsSection
|
||||||
<h4 className="text-sm font-semibold text-slate-300 mb-3">🔹 FLOW DETAILS</h4>
|
row={row}
|
||||||
|
flowTrend={flowTrend}
|
||||||
{/* Flow Trend Visual */}
|
lastFlowMinutesAgo={lastFlowMinutesAgo}
|
||||||
{flowTrend && (
|
hoursAgo={hoursAgo}
|
||||||
<div className={cn(
|
netPremium={netPremium}
|
||||||
"mb-4 p-4 border-2 rounded-lg",
|
bullTotal={bullTotal}
|
||||||
flowTrend.color === 'red' && flowTrend.label === 'DEAD'
|
bearTotal={bearTotal}
|
||||||
? "bg-red-900/30 border-red-500"
|
volume={volume}
|
||||||
: flowTrend.color === 'red' && flowTrend.label === 'SURGING'
|
oi={oi}
|
||||||
? "bg-red-900/30 border-red-500"
|
/>
|
||||||
: flowTrend.color === 'yellow'
|
|
||||||
? "bg-yellow-900/30 border-yellow-500"
|
|
||||||
: "bg-orange-900/30 border-orange-500"
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-5xl">{flowTrend.icon}</span>
|
|
||||||
<div>
|
|
||||||
<div className={cn(
|
|
||||||
"text-2xl font-bold",
|
|
||||||
flowTrend.color === 'red' ? "text-red-400" :
|
|
||||||
flowTrend.color === 'yellow' ? "text-yellow-400" :
|
|
||||||
"text-orange-400"
|
|
||||||
)}>
|
|
||||||
{flowTrend.label} FLOW
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-300">
|
|
||||||
Last activity: <span className={cn(
|
|
||||||
"font-bold",
|
|
||||||
flowTrend.color === 'red' ? "text-red-300" :
|
|
||||||
flowTrend.color === 'yellow' ? "text-yellow-300" :
|
|
||||||
"text-orange-300"
|
|
||||||
)}>
|
|
||||||
{lastFlowMinutesAgo} minutes ago {hoursAgo > 0 && `(${hoursAgo} ${hoursAgo === 1 ? 'hour' : 'hours'})`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={cn(
|
|
||||||
"text-sm mt-1",
|
|
||||||
flowTrend.color === 'red' ? "text-yellow-300" :
|
|
||||||
flowTrend.color === 'yellow' ? "text-yellow-200" :
|
|
||||||
"text-orange-200"
|
|
||||||
)}>
|
|
||||||
⚠️ {flowTrend.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 text-xs">
|
{/* Institutional Analytics - Phase 1 */}
|
||||||
<div>
|
<InstitutionalAnalyticsSection row={row} />
|
||||||
<span className="text-slate-500">Net Premium:</span>
|
|
||||||
<span className={cn(
|
{/* Phase 3: Advanced Dealer & Regime Analytics */}
|
||||||
"ml-2 font-semibold",
|
<DealerRegimeSection row={row} />
|
||||||
netPremium > 0 ? "text-green-400" : "text-red-400"
|
|
||||||
)}>
|
{/* Time-Sequenced Metrics - Separate Section */}
|
||||||
${(Math.abs(netPremium) / 1000000).toFixed(2)}M
|
<TimeSequencedSection row={row} />
|
||||||
{netPremium > 0 ? ` (${((netPremium / (bullTotal + bearTotal)) * 100).toFixed(0)}% Bullish)` : ` (${((Math.abs(netPremium) / (bullTotal + bearTotal)) * 100).toFixed(0)}% Bearish)`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Premium Breakdown:</span>
|
|
||||||
<div className="ml-2 mt-1">
|
|
||||||
<div className="text-green-400">${(bullTotal / 1000000).toFixed(2)}M Calls</div>
|
|
||||||
<div className="text-red-400">${(bearTotal / 1000000).toFixed(2)}M Puts</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Flow Type:</span>
|
|
||||||
<span className="ml-2 text-slate-300">
|
|
||||||
{row.cp_norm === 'CALL' ? 'ITM Calls' : 'ITM Puts'}
|
|
||||||
{row.badgesRaw?.more?.includes('💎') ? ' (💎 Real Money)' : ' (Speculation)'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Volume:</span>
|
|
||||||
<span className="ml-2 text-slate-300">{volume.toLocaleString()} contracts</span>
|
|
||||||
</div>
|
|
||||||
{oi > 0 ? (
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Open Interest:</span>
|
|
||||||
<span className="ml-2 text-slate-300">
|
|
||||||
{oi.toLocaleString()} OI {oi >= 12500 ? '(High)' : oi >= 5000 ? '(Medium)' : '(Low)'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Open Interest:</span>
|
|
||||||
<span className="ml-2 text-slate-300 text-slate-400 italic">New flow (no OI yet)</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price Context */}
|
{/* Price Context */}
|
||||||
<div className="space-y-3">
|
<PriceContextSection
|
||||||
<h4 className="text-sm font-semibold text-slate-300 mb-3">📊 PRICE CONTEXT</h4>
|
row={row}
|
||||||
<div className="space-y-2 text-xs">
|
currentPrice={currentPrice}
|
||||||
<div>
|
vwap={vwap}
|
||||||
<span className="text-slate-500">Current:</span>
|
rthOpen={rthOpen}
|
||||||
<span className="ml-2 text-slate-200 font-semibold">
|
pctVsRthOpen={pctVsRthOpen}
|
||||||
${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}
|
pctVsPriorClose={pctVsPriorClose}
|
||||||
</span>
|
spot={spot}
|
||||||
</div>
|
moneynessPct={moneynessPct}
|
||||||
<div>
|
session={session}
|
||||||
<span className="text-slate-500">VWAP:</span>
|
tapeAlign={tapeAlign}
|
||||||
<span className="ml-2 text-slate-200">
|
/>
|
||||||
${typeof vwap === 'number' ? vwap.toFixed(2) : 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">RTH Open:</span>
|
|
||||||
<span className="ml-2 text-slate-200">
|
|
||||||
${typeof rthOpen === 'number' ? rthOpen.toFixed(2) : 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{Math.abs(pctVsRthOpen) < 0.01 && Math.abs(pctVsPriorClose) < 0.01 ? (
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Price Change:</span>
|
|
||||||
<span className="ml-2 text-slate-300 font-semibold">0.00% (flat since open)</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">vs RTH Open:</span>
|
|
||||||
<span className={cn(
|
|
||||||
"ml-2 font-semibold",
|
|
||||||
pctVsRthOpen > 0 ? "text-green-400" : "text-red-400"
|
|
||||||
)}>
|
|
||||||
{pctVsRthOpen > 0 ? '+' : ''}{typeof pctVsRthOpen === 'number' ? pctVsRthOpen.toFixed(2) : '0.00'}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">vs Prior Close:</span>
|
|
||||||
<span className={cn(
|
|
||||||
"ml-2 font-semibold",
|
|
||||||
pctVsPriorClose > 0 ? "text-green-400" : "text-red-400"
|
|
||||||
)}>
|
|
||||||
{pctVsPriorClose > 0 ? '+' : ''}{typeof pctVsPriorClose === 'number' ? pctVsPriorClose.toFixed(2) : '0.00'}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Spot Price:</span>
|
|
||||||
<span className="ml-2 text-slate-200 font-semibold">
|
|
||||||
${typeof spot === 'number' ? spot.toFixed(2) : spot || 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Moneyness:</span>
|
|
||||||
<span className={cn(
|
|
||||||
"ml-2 font-semibold",
|
|
||||||
moneynessPct > 0 ? "text-green-400" : moneynessPct < 0 ? "text-red-400" : "text-slate-300"
|
|
||||||
)}>
|
|
||||||
{typeof moneynessPct === 'number' ? moneynessPct.toFixed(2) : moneynessPct || '0.00'}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Session:</span>
|
|
||||||
<span className="ml-2 text-slate-300">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{session}
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tape Alignment Visual */}
|
|
||||||
{tapeAlign !== '—' ? (
|
|
||||||
<div className="mt-4 p-4 bg-green-900/30 border-2 border-green-500 rounded-lg">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">✅</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-xl font-bold text-green-400">TAPE ALIGNED</div>
|
|
||||||
<div className="text-sm text-gray-300">Price confirming flow direction ↑</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 p-4 bg-orange-900/30 border-2 border-orange-500 rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">⚠️</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-xl font-bold text-orange-400">NO TAPE ALIGNMENT</div>
|
|
||||||
<div className="text-sm text-gray-300">Price not confirming flow direction</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xs text-gray-400">Current Price</div>
|
|
||||||
<div className="text-2xl font-bold text-orange-300">${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}</div>
|
|
||||||
<div className="text-xs text-gray-400">Expected: Upward momentum</div>
|
|
||||||
<div className="text-xs text-red-300">Reality: Flat/down</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timing & Dates */}
|
{/* Timing & Dates */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-semibold text-slate-300 mb-3">📅 TIMING & DATES</h4>
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">📅 TIMING & DATES</h4>
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-500">Created Date:</span>
|
<span className="text-slate-500">Created Date:</span>
|
||||||
|
|
@ -387,7 +219,7 @@ export function ExpandedRowDetails({ row }) {
|
||||||
|
|
||||||
{/* Catalyst */}
|
{/* Catalyst */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-semibold text-slate-300 mb-3">⚡ CATALYST</h4>
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">⚡ CATALYST</h4>
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2 text-xs">
|
||||||
{catalyst !== 'None' ? (
|
{catalyst !== 'None' ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -490,183 +322,14 @@ export function ExpandedRowDetails({ row }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phase 1 & AI Analysis */}
|
{/* Phase 1 & AI Analysis */}
|
||||||
<div className="space-y-3 md:col-span-2 lg:col-span-3">
|
<AnalysisSection
|
||||||
<div className="flex items-center justify-between mb-3 gap-2">
|
row={row}
|
||||||
<h4 className="text-sm font-semibold text-slate-300">🔍 ANALYSIS</h4>
|
hasPhase1Data={hasPhase1Data}
|
||||||
<div className="flex gap-2">
|
onPhase1Click={() => setShowPhase1Modal(true)}
|
||||||
{/* Phase 1 Eligibility Check */}
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowPhase1Modal(true)}
|
|
||||||
className={cn(
|
|
||||||
"text-xs",
|
|
||||||
hasPhase1Data
|
|
||||||
? "bg-blue-500/20 border-blue-500/50 text-blue-400 hover:bg-blue-500/30"
|
|
||||||
: "bg-slate-700/50 border-slate-600 text-slate-400"
|
|
||||||
)}
|
|
||||||
title={hasPhase1Data ? "Check Phase 1 Eligibility" : "Phase 1 data not available"}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
|
||||||
Phase 1
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* AI Analysis */}
|
|
||||||
{!showAIAnalysis && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowAIAnalysis(true);
|
|
||||||
fetchAnalysis(row);
|
|
||||||
}}
|
|
||||||
disabled={aiLoading}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{aiLoading ? 'Analyzing...' : 'AI Analysis'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAIAnalysis && (
|
|
||||||
<div className="bg-slate-800/50 rounded-lg p-4 space-y-3">
|
|
||||||
{aiLoading && (
|
|
||||||
<div className="text-sm text-slate-400">Analyzing with Claude AI...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{aiError && (
|
|
||||||
<div className="text-sm text-red-400">Error: {aiError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{aiAnalysis && aiAnalysis.analysis && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
aiAnalysis.analysis.assessment === 'BULLISH' ? 'success' :
|
|
||||||
aiAnalysis.analysis.assessment === 'BEARISH' ? 'destructive' : 'secondary'
|
|
||||||
}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{aiAnalysis.analysis.assessment}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
aiAnalysis.analysis.recommendation === 'ENTER' ? 'success' :
|
|
||||||
aiAnalysis.analysis.recommendation === 'WAIT' ? 'warning' : 'destructive'
|
|
||||||
}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{aiAnalysis.analysis.recommendation}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{aiAnalysis.analysis.riskLevel} Risk
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-200">{aiAnalysis.analysis.summary}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{aiAnalysis.analysis.keyFactors && aiAnalysis.analysis.keyFactors.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Key Factors:</div>
|
|
||||||
<ul className="text-xs text-slate-300 space-y-1">
|
|
||||||
{aiAnalysis.analysis.keyFactors.map((factor, idx) => (
|
|
||||||
<li key={idx}>• {factor}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{aiAnalysis.analysis.reasoning && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Reasoning:</div>
|
|
||||||
<p className="text-xs text-slate-300">{aiAnalysis.analysis.reasoning}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-xs text-slate-400 pt-2 border-t border-slate-700">
|
|
||||||
<span>Time Horizon: {aiAnalysis.analysis.timeHorizon}</span>
|
|
||||||
{aiAnalysis.timestamp && (
|
|
||||||
<span>• {new Date(aiAnalysis.timestamp).toLocaleTimeString()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last 5 Days Volume History - Table Format */}
|
{/* Last 5 Days Volume History - Table Format */}
|
||||||
{row.volumeHistory && row.volumeHistory.length > 0 && (
|
<VolumeHistorySection row={row} />
|
||||||
<div className="space-y-3 md:col-span-2 lg:col-span-3 mt-6 pt-6 border-t border-slate-700/50">
|
|
||||||
<h4 className="text-sm font-semibold text-slate-300 mb-3">📊 LAST 5 DAYS VOLUME</h4>
|
|
||||||
<div className="bg-slate-800/50 rounded-lg overflow-hidden">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-700/50 bg-slate-800/70">
|
|
||||||
<th className="text-left py-3 px-4 text-slate-400 font-semibold">Date</th>
|
|
||||||
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Volume</th>
|
|
||||||
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Open</th>
|
|
||||||
<th className="text-right py-3 px-4 text-slate-400 font-semibold">High</th>
|
|
||||||
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Low</th>
|
|
||||||
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Close</th>
|
|
||||||
<th className="text-right py-3 px-4 text-slate-400 font-semibold">VWAP</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{row.volumeHistory.map((day, idx) => {
|
|
||||||
const date = new Date(day.date);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const yesterday = new Date(today);
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
const dayDate = new Date(date);
|
|
||||||
dayDate.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
let dayLabel;
|
|
||||||
if (dayDate.getTime() === today.getTime()) {
|
|
||||||
dayLabel = 'Today';
|
|
||||||
} else if (dayDate.getTime() === yesterday.getTime()) {
|
|
||||||
dayLabel = 'Yesterday';
|
|
||||||
} else {
|
|
||||||
dayLabel = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format volume with K/M/B
|
|
||||||
const formatVolume = (vol) => {
|
|
||||||
if (!vol || vol === 0) return '—';
|
|
||||||
const absVal = Math.abs(vol);
|
|
||||||
if (absVal >= 1e9) return `${(absVal / 1e9).toFixed(2)}B`;
|
|
||||||
if (absVal >= 1e6) return `${(absVal / 1e6).toFixed(2)}M`;
|
|
||||||
if (absVal >= 1e3) return `${(absVal / 1e3).toFixed(2)}K`;
|
|
||||||
return absVal.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format price
|
|
||||||
const formatPrice = (price) => {
|
|
||||||
if (!price || price === 0) return '—';
|
|
||||||
return `$${parseFloat(price).toFixed(2)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={idx} className="border-b border-slate-700/30 hover:bg-slate-800/70 transition-colors">
|
|
||||||
<td className="py-3 px-4 text-slate-300 font-medium">{dayLabel}</td>
|
|
||||||
<td className="py-3 px-4 text-right text-slate-200 font-mono">{formatVolume(day.volume)}</td>
|
|
||||||
<td className="py-3 px-4 text-right text-slate-300 font-mono">{formatPrice(day.open)}</td>
|
|
||||||
<td className="py-3 px-4 text-right text-green-400 font-mono">{formatPrice(day.high)}</td>
|
|
||||||
<td className="py-3 px-4 text-right text-red-400 font-mono">{formatPrice(day.low)}</td>
|
|
||||||
<td className="py-3 px-4 text-right text-slate-200 font-mono font-semibold">{formatPrice(day.close)}</td>
|
|
||||||
<td className="py-3 px-4 text-right text-blue-400 font-mono font-semibold">{formatPrice(day.vwap)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phase 1 Eligibility Modal */}
|
{/* Phase 1 Eligibility Modal */}
|
||||||
|
|
@ -678,4 +341,3 @@ export function ExpandedRowDetails({ row }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '../../ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { useAIAnalysis } from '@/hooks/useAIAnalysis';
|
||||||
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export function AnalysisSection({ row, hasPhase1Data, onPhase1Click }) {
|
||||||
|
const [showAIAnalysis, setShowAIAnalysis] = useState(false);
|
||||||
|
const { loading: aiLoading, error: aiError, analysis: aiAnalysis, fetchAnalysis } = useAIAnalysis();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 md:col-span-2 lg:col-span-3">
|
||||||
|
<div className="flex items-center justify-between mb-3 gap-2">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300">🔍 ANALYSIS</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* Phase 1 Eligibility Check */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPhase1Click}
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
hasPhase1Data
|
||||||
|
? "bg-blue-500/20 border-blue-500/50 text-blue-400 hover:bg-blue-500/30"
|
||||||
|
: "bg-slate-700/50 border-slate-600 text-slate-400"
|
||||||
|
)}
|
||||||
|
title={hasPhase1Data ? "Check Phase 1 Eligibility" : "Phase 1 data not available"}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Phase 1
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* AI Analysis */}
|
||||||
|
{!showAIAnalysis && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAIAnalysis(true);
|
||||||
|
fetchAnalysis(row);
|
||||||
|
}}
|
||||||
|
disabled={aiLoading}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{aiLoading ? 'Analyzing...' : 'AI Analysis'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAIAnalysis && (
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-4 space-y-3">
|
||||||
|
{aiLoading && (
|
||||||
|
<div className="text-sm text-slate-400">Analyzing with Claude AI...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiError && (
|
||||||
|
<div className="text-sm text-red-400">Error: {aiError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiAnalysis && aiAnalysis.analysis && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
aiAnalysis.analysis.assessment === 'BULLISH' ? 'success' :
|
||||||
|
aiAnalysis.analysis.assessment === 'BEARISH' ? 'destructive' : 'secondary'
|
||||||
|
}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{aiAnalysis.analysis.assessment}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
aiAnalysis.analysis.recommendation === 'ENTER' ? 'success' :
|
||||||
|
aiAnalysis.analysis.recommendation === 'WAIT' ? 'warning' : 'destructive'
|
||||||
|
}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{aiAnalysis.analysis.recommendation}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{aiAnalysis.analysis.riskLevel} Risk
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-200">{aiAnalysis.analysis.summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiAnalysis.analysis.keyFactors && aiAnalysis.analysis.keyFactors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-1">Key Factors:</div>
|
||||||
|
<ul className="text-xs text-slate-300 space-y-1">
|
||||||
|
{aiAnalysis.analysis.keyFactors.map((factor, idx) => (
|
||||||
|
<li key={idx}>• {factor}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiAnalysis.analysis.reasoning && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-1">Reasoning:</div>
|
||||||
|
<p className="text-xs text-slate-300">{aiAnalysis.analysis.reasoning}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-slate-400 pt-2 border-t border-slate-700">
|
||||||
|
<span>Time Horizon: {aiAnalysis.analysis.timeHorizon}</span>
|
||||||
|
{aiAnalysis.timestamp && (
|
||||||
|
<span>• {new Date(aiAnalysis.timestamp).toLocaleTimeString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
export function DealerRegimeSection({ row }) {
|
||||||
|
const hasDealerData = (row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined) ||
|
||||||
|
row.market_regime || row.volatility_intent;
|
||||||
|
|
||||||
|
if (!hasDealerData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">🎯 DEALER & REGIME ANALYSIS</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{/* Dealer Hedge Pressure */}
|
||||||
|
{(row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined) && (
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||||
|
<div className="text-xs text-slate-500 mb-2">Dealer Hedge Pressure</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-3xl font-bold text-orange-400">
|
||||||
|
{Math.round(row.dealer_hedge_pressure_score)}
|
||||||
|
</div>
|
||||||
|
{row.dealer_hedge_pressure_score !== null && row.dealer_hedge_pressure_score !== undefined && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="w-full bg-slate-700 rounded-full h-2 mb-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 rounded-full transition-all",
|
||||||
|
row.dealer_hedge_pressure_score >= 70 ? "bg-red-500" :
|
||||||
|
row.dealer_hedge_pressure_score >= 50 ? "bg-orange-500" :
|
||||||
|
"bg-yellow-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, row.dealer_hedge_pressure_score)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{row.dealer_hedge_pressure_score >= 70 ? "High Pressure" :
|
||||||
|
row.dealer_hedge_pressure_score >= 50 ? "Moderate" : "Low"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.net_gamma_exposure_per_symbol !== null && row.net_gamma_exposure_per_symbol !== undefined && (
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
Net Gamma: {row.net_gamma_exposure_per_symbol >= 1e9
|
||||||
|
? `${(row.net_gamma_exposure_per_symbol / 1e9).toFixed(2)}B`
|
||||||
|
: row.net_gamma_exposure_per_symbol >= 1e6
|
||||||
|
? `${(row.net_gamma_exposure_per_symbol / 1e6).toFixed(1)}M`
|
||||||
|
: `${(row.net_gamma_exposure_per_symbol / 1e3).toFixed(0)}K`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Market Regime */}
|
||||||
|
{row.market_regime && (
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||||
|
<div className="text-xs text-slate-500 mb-2">Market Regime</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{row.market_regime === 'TREND' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">📈</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-green-400">TREND</div>
|
||||||
|
<div className="text-xs text-slate-400">Continuation bias</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.market_regime === 'RANGE' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">↔️</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-yellow-400">RANGE</div>
|
||||||
|
<div className="text-xs text-slate-400">Fade or vol-sell bias</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.market_regime === 'HIGH_VOL_EVENT' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">⚡</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-red-400">HIGH VOL EVENT</div>
|
||||||
|
<div className="text-xs text-slate-400">Volatility expansion bias</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Volatility Intent */}
|
||||||
|
{row.volatility_intent && (
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||||
|
<div className="text-xs text-slate-500 mb-2">Volatility Intent</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{row.volatility_intent === 'LONG_VOL' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">📊</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-green-400">LONG VOL</div>
|
||||||
|
<div className="text-xs text-slate-400">Buying volatility</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.volatility_intent === 'SHORT_VOL' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">📉</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-red-400">SHORT VOL</div>
|
||||||
|
<div className="text-xs text-slate-400">Selling volatility</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.volatility_intent === 'DIRECTIONAL' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">➡️</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-blue-400">DIRECTIONAL</div>
|
||||||
|
<div className="text-xs text-slate-400">Directional positioning</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.volatility_intent === 'HEDGE_UNWIND' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">🔄</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-orange-400">HEDGE/UNWIND</div>
|
||||||
|
<div className="text-xs text-slate-400">Hedging or unwinding</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.delta_exposure !== null && row.delta_exposure !== undefined && (
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
Delta Exp: {Math.abs(row.delta_exposure) >= 1e6
|
||||||
|
? `${(row.delta_exposure / 1e6).toFixed(1)}M`
|
||||||
|
: `${(row.delta_exposure / 1e3).toFixed(0)}K`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Dealer Metrics */}
|
||||||
|
{(row.gamma_flip_proximity !== null || row.gamma_exposure !== null ||
|
||||||
|
row.dealer_pain_level !== null || row.flow_state) && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-700 grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
|
{row.gamma_flip_proximity !== null && row.gamma_flip_proximity !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Gamma Flip Proximity:</span>
|
||||||
|
<span className="ml-2 text-slate-300 font-semibold">
|
||||||
|
{row.gamma_flip_proximity.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{row.gamma_flip_proximity > 0.5 ? "Long gamma zone" :
|
||||||
|
row.gamma_flip_proximity < -0.5 ? "Short gamma zone" :
|
||||||
|
"Near flip point"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.gamma_exposure !== null && row.gamma_exposure !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Gamma Exposure:</span>
|
||||||
|
<span className="ml-2 text-slate-300 font-semibold">
|
||||||
|
{Math.abs(row.gamma_exposure) >= 1e9
|
||||||
|
? `${(row.gamma_exposure / 1e9).toFixed(2)}B`
|
||||||
|
: Math.abs(row.gamma_exposure) >= 1e6
|
||||||
|
? `${(row.gamma_exposure / 1e6).toFixed(1)}M`
|
||||||
|
: `${(row.gamma_exposure / 1e3).toFixed(0)}K`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.dealer_pain_level !== null && row.dealer_pain_level !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Dealer Pain Level:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
row.dealer_pain_level >= 70 ? "text-red-400" :
|
||||||
|
row.dealer_pain_level >= 50 ? "text-orange-400" :
|
||||||
|
"text-yellow-400"
|
||||||
|
)}>
|
||||||
|
{Math.round(row.dealer_pain_level)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.flow_state && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Flow State:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
row.flow_state === 'ACTIONABLE' ? "text-green-400" : "text-slate-400"
|
||||||
|
)}>
|
||||||
|
{row.flow_state === 'ACTIONABLE' ? '✅ ACTIONABLE' : '📋 INFORMATIONAL'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
export function FlowDetailsSection({
|
||||||
|
row,
|
||||||
|
flowTrend,
|
||||||
|
lastFlowMinutesAgo,
|
||||||
|
hoursAgo,
|
||||||
|
netPremium,
|
||||||
|
bullTotal,
|
||||||
|
bearTotal,
|
||||||
|
volume,
|
||||||
|
oi
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">🔹 FLOW DETAILS</h4>
|
||||||
|
|
||||||
|
{/* Flow Trend Visual */}
|
||||||
|
{flowTrend && (
|
||||||
|
<div className={cn(
|
||||||
|
"mb-4 p-4 border-2 rounded-lg",
|
||||||
|
flowTrend.color === 'red' && flowTrend.label === 'DEAD'
|
||||||
|
? "bg-red-900/30 border-red-500"
|
||||||
|
: flowTrend.color === 'red' && flowTrend.label === 'SURGING'
|
||||||
|
? "bg-red-900/30 border-red-500"
|
||||||
|
: flowTrend.color === 'yellow'
|
||||||
|
? "bg-yellow-900/30 border-yellow-500"
|
||||||
|
: "bg-orange-900/30 border-orange-500"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-5xl">{flowTrend.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className={cn(
|
||||||
|
"text-2xl font-bold",
|
||||||
|
flowTrend.color === 'red' ? "text-red-400" :
|
||||||
|
flowTrend.color === 'yellow' ? "text-yellow-400" :
|
||||||
|
"text-orange-400"
|
||||||
|
)}>
|
||||||
|
{flowTrend.label} FLOW
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300">
|
||||||
|
Last activity: <span className={cn(
|
||||||
|
"font-bold",
|
||||||
|
flowTrend.color === 'red' ? "text-red-300" :
|
||||||
|
flowTrend.color === 'yellow' ? "text-yellow-300" :
|
||||||
|
"text-orange-300"
|
||||||
|
)}>
|
||||||
|
{lastFlowMinutesAgo} minutes ago {hoursAgo > 0 && `(${hoursAgo} ${hoursAgo === 1 ? 'hour' : 'hours'})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"text-sm mt-1",
|
||||||
|
flowTrend.color === 'red' ? "text-yellow-300" :
|
||||||
|
flowTrend.color === 'yellow' ? "text-yellow-200" :
|
||||||
|
"text-orange-200"
|
||||||
|
)}>
|
||||||
|
⚠️ {flowTrend.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Net Premium:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
netPremium > 0 ? "text-green-400" : "text-red-400"
|
||||||
|
)}>
|
||||||
|
${(Math.abs(netPremium) / 1000000).toFixed(2)}M
|
||||||
|
{netPremium > 0 ? ` (${((netPremium / (bullTotal + bearTotal)) * 100).toFixed(0)}% Bullish)` : ` (${((Math.abs(netPremium) / (bullTotal + bearTotal)) * 100).toFixed(0)}% Bearish)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Premium Breakdown:</span>
|
||||||
|
<div className="ml-2 mt-1">
|
||||||
|
<div className="text-green-400">${(bullTotal / 1000000).toFixed(2)}M Calls</div>
|
||||||
|
<div className="text-red-400">${(bearTotal / 1000000).toFixed(2)}M Puts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Flow Type:</span>
|
||||||
|
<span className="ml-2 text-slate-300">
|
||||||
|
{row.cp_norm === 'CALL' ? 'ITM Calls' : 'ITM Puts'}
|
||||||
|
{row.badgesRaw?.more?.includes('💎') ? ' (💎 Real Money)' : ' (Speculation)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Volume:</span>
|
||||||
|
<span className="ml-2 text-slate-300">{volume.toLocaleString()} contracts</span>
|
||||||
|
</div>
|
||||||
|
{oi > 0 ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Open Interest:</span>
|
||||||
|
<span className="ml-2 text-slate-300">
|
||||||
|
{oi.toLocaleString()} OI {oi >= 12500 ? '(High)' : oi >= 5000 ? '(Medium)' : '(Low)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Open Interest:</span>
|
||||||
|
<span className="ml-2 text-slate-300 text-slate-400 italic">New flow (no OI yet)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
export function InstitutionalAnalyticsSection({ row }) {
|
||||||
|
const hasInstitutionalData = (row.confidence_score !== null && row.confidence_score !== undefined) ||
|
||||||
|
(row.signal_strength !== null && row.signal_strength !== undefined) ||
|
||||||
|
(row.relative_premium_score !== null && row.relative_premium_score !== undefined);
|
||||||
|
|
||||||
|
if (!hasInstitutionalData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">🏛️ INSTITUTIONAL ANALYTICS</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{/* Confidence Score */}
|
||||||
|
{(row.confidence_score !== null && row.confidence_score !== undefined) && (
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||||
|
<div className="text-xs text-slate-500 mb-2">Confidence Score</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-3xl font-bold text-blue-400">
|
||||||
|
{Math.round(row.confidence_score)}%
|
||||||
|
</div>
|
||||||
|
{row.confidence_score !== null && row.confidence_score !== undefined && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="w-full bg-slate-700 rounded-full h-2 mb-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 rounded-full transition-all",
|
||||||
|
row.confidence_score >= 70 ? "bg-green-500" :
|
||||||
|
row.confidence_score >= 50 ? "bg-yellow-500" :
|
||||||
|
"bg-orange-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, row.confidence_score)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{row.confidence_score >= 70 ? "High Confidence" :
|
||||||
|
row.confidence_score >= 50 ? "Moderate" : "Low"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signal Strength */}
|
||||||
|
{(row.signal_strength !== null && row.signal_strength !== undefined) && (
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||||
|
<div className="text-xs text-slate-500 mb-2">Signal Strength</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-3xl font-bold text-purple-400">
|
||||||
|
{Math.round(row.signal_strength)}
|
||||||
|
</div>
|
||||||
|
{row.signal_strength !== null && row.signal_strength !== undefined && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="w-full bg-slate-700 rounded-full h-2 mb-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 rounded-full transition-all",
|
||||||
|
row.signal_strength >= 70 ? "bg-purple-500" :
|
||||||
|
row.signal_strength >= 50 ? "bg-blue-500" :
|
||||||
|
"bg-slate-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, row.signal_strength)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{row.signal_strength >= 70 ? "Strong" :
|
||||||
|
row.signal_strength >= 50 ? "Moderate" : "Weak"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Relative Premium Score */}
|
||||||
|
{(row.relative_premium_score !== null && row.relative_premium_score !== undefined) && (
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||||
|
<div className="text-xs text-slate-500 mb-2">Relative Premium</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-3xl font-bold text-green-400">
|
||||||
|
{Math.round(row.relative_premium_score)}
|
||||||
|
</div>
|
||||||
|
{row.relative_premium_score !== null && row.relative_premium_score !== undefined && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="w-full bg-slate-700 rounded-full h-2 mb-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 rounded-full transition-all",
|
||||||
|
row.relative_premium_score >= 70 ? "bg-green-500" :
|
||||||
|
row.relative_premium_score >= 50 ? "bg-yellow-500" :
|
||||||
|
"bg-orange-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, row.relative_premium_score)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{row.relative_premium_score >= 70 ? "Significant" :
|
||||||
|
row.relative_premium_score >= 50 ? "Moderate" : "Low"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.premium_zscore !== null && row.premium_zscore !== undefined && (
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
Z-Score: {row.premium_zscore.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional context metrics */}
|
||||||
|
{(row.premium_percentile_intraday !== null || row.aggression_score !== null ||
|
||||||
|
row.size_concentration_score !== null || row.institutional_likelihood !== null) && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-700 grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
|
{row.premium_percentile_intraday !== null && row.premium_percentile_intraday !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Intraday Percentile:</span>
|
||||||
|
<span className="ml-2 text-slate-300 font-semibold">
|
||||||
|
{row.premium_percentile_intraday.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.aggression_score !== null && row.aggression_score !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Aggression:</span>
|
||||||
|
<span className="ml-2 text-slate-300 font-semibold">
|
||||||
|
{row.aggression_score.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.size_concentration_score !== null && row.size_concentration_score !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Size Concentration:</span>
|
||||||
|
<span className="ml-2 text-slate-300 font-semibold">
|
||||||
|
{row.size_concentration_score.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.institutional_likelihood !== null && row.institutional_likelihood !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Institutional Likelihood:</span>
|
||||||
|
<span className="ml-2 text-slate-300 font-semibold text-blue-400">
|
||||||
|
{(row.institutional_likelihood * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { Badge } from '../../ui/Badge';
|
||||||
|
|
||||||
|
export function PriceContextSection({
|
||||||
|
row,
|
||||||
|
currentPrice,
|
||||||
|
vwap,
|
||||||
|
rthOpen,
|
||||||
|
pctVsRthOpen,
|
||||||
|
pctVsPriorClose,
|
||||||
|
spot,
|
||||||
|
moneynessPct,
|
||||||
|
session,
|
||||||
|
tapeAlign
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">📊 PRICE CONTEXT</h4>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Current:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">VWAP:</span>
|
||||||
|
<span className="ml-2 text-slate-200">
|
||||||
|
${typeof vwap === 'number' ? vwap.toFixed(2) : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">RTH Open:</span>
|
||||||
|
<span className="ml-2 text-slate-200">
|
||||||
|
${typeof rthOpen === 'number' ? rthOpen.toFixed(2) : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Math.abs(pctVsRthOpen) < 0.01 && Math.abs(pctVsPriorClose) < 0.01 ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Price Change:</span>
|
||||||
|
<span className="ml-2 text-slate-300 font-semibold">0.00% (flat since open)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">vs RTH Open:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
pctVsRthOpen > 0 ? "text-green-400" : "text-red-400"
|
||||||
|
)}>
|
||||||
|
{pctVsRthOpen > 0 ? '+' : ''}{typeof pctVsRthOpen === 'number' ? pctVsRthOpen.toFixed(2) : '0.00'}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">vs Prior Close:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
pctVsPriorClose > 0 ? "text-green-400" : "text-red-400"
|
||||||
|
)}>
|
||||||
|
{pctVsPriorClose > 0 ? '+' : ''}{typeof pctVsPriorClose === 'number' ? pctVsPriorClose.toFixed(2) : '0.00'}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Spot Price:</span>
|
||||||
|
<span className="ml-2 text-slate-200 font-semibold">
|
||||||
|
${typeof spot === 'number' ? spot.toFixed(2) : spot || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Moneyness:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
moneynessPct > 0 ? "text-green-400" : moneynessPct < 0 ? "text-red-400" : "text-slate-300"
|
||||||
|
)}>
|
||||||
|
{typeof moneynessPct === 'number' ? moneynessPct.toFixed(2) : moneynessPct || '0.00'}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Session:</span>
|
||||||
|
<span className="ml-2 text-slate-300">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{session}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tape Alignment Visual */}
|
||||||
|
{tapeAlign !== '—' ? (
|
||||||
|
<div className="mt-4 p-4 bg-green-900/30 border-2 border-green-500 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-4xl">✅</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-green-400">TAPE ALIGNED</div>
|
||||||
|
<div className="text-sm text-gray-300">Price confirming flow direction ↑</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 p-4 bg-orange-900/30 border-2 border-orange-500 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-4xl">⚠️</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-orange-400">NO TAPE ALIGNMENT</div>
|
||||||
|
<div className="text-sm text-gray-300">Price not confirming flow direction</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-gray-400">Current Price</div>
|
||||||
|
<div className="text-2xl font-bold text-orange-300">${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : 'N/A'}</div>
|
||||||
|
<div className="text-xs text-gray-400">Expected: Upward momentum</div>
|
||||||
|
<div className="text-xs text-red-300">Reality: Flat/down</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
export function TimeSequencedSection({ row }) {
|
||||||
|
const hasTimeSequencedData = (row.flow_acceleration !== null && row.flow_acceleration !== undefined) ||
|
||||||
|
(row.time_between_hits !== null && row.time_between_hits !== undefined) ||
|
||||||
|
(row.follow_on_ratio !== null && row.follow_on_ratio !== undefined) ||
|
||||||
|
row.strike_laddering_detected;
|
||||||
|
|
||||||
|
if (!hasTimeSequencedData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 lg:col-span-2">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 border-b border-slate-700 pb-2">⏱️ TIME-SEQUENCED METRICS</h4>
|
||||||
|
<div className="bg-slate-800/30 rounded-lg p-4 border border-slate-700">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
|
||||||
|
{row.flow_acceleration !== null && row.flow_acceleration !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Flow Acceleration:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
row.flow_acceleration > 0 ? "text-green-400" : "text-red-400"
|
||||||
|
)}>
|
||||||
|
{row.flow_acceleration >= 1e6
|
||||||
|
? `${(row.flow_acceleration / 1e6).toFixed(2)}M/min`
|
||||||
|
: `${(row.flow_acceleration / 1e3).toFixed(0)}K/min`}
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{row.flow_acceleration > 0 ? "Escalating" : "Decelerating"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.time_between_hits !== null && row.time_between_hits !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Time Between Hits:</span>
|
||||||
|
<span className="ml-2 text-slate-300 font-semibold">
|
||||||
|
{row.time_between_hits.toFixed(1)} min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.follow_on_ratio !== null && row.follow_on_ratio !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Follow-On Ratio:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
row.follow_on_ratio >= 0.7 ? "text-green-400" :
|
||||||
|
row.follow_on_ratio >= 0.4 ? "text-yellow-400" :
|
||||||
|
"text-red-400"
|
||||||
|
)}>
|
||||||
|
{(row.follow_on_ratio * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{row.follow_on_ratio >= 0.7 ? "Continuation" :
|
||||||
|
row.follow_on_ratio >= 0.4 ? "Mixed" : "Reversal"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.strike_laddering_detected !== null && row.strike_laddering_detected !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Strike Laddering:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 font-semibold",
|
||||||
|
row.strike_laddering_detected ? "text-green-400" : "text-slate-500"
|
||||||
|
)}>
|
||||||
|
{row.strike_laddering_detected ? '✅ Detected' : '❌ None'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
export function VolumeHistorySection({ row }) {
|
||||||
|
if (!row.volumeHistory || row.volumeHistory.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 md:col-span-2 lg:col-span-3 mt-6 pt-6 border-t border-slate-700/50">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300 mb-3">📊 LAST 5 DAYS VOLUME</h4>
|
||||||
|
<div className="bg-slate-800/50 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-700/50 bg-slate-800/70">
|
||||||
|
<th className="text-left py-3 px-4 text-slate-400 font-semibold">Date</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Volume</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Open</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-semibold">High</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Low</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-semibold">Close</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-semibold">VWAP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{row.volumeHistory.map((day, idx) => {
|
||||||
|
const date = new Date(day.date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const dayDate = new Date(date);
|
||||||
|
dayDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
let dayLabel;
|
||||||
|
if (dayDate.getTime() === today.getTime()) {
|
||||||
|
dayLabel = 'Today';
|
||||||
|
} else if (dayDate.getTime() === yesterday.getTime()) {
|
||||||
|
dayLabel = 'Yesterday';
|
||||||
|
} else {
|
||||||
|
dayLabel = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format volume with K/M/B
|
||||||
|
const formatVolume = (vol) => {
|
||||||
|
if (!vol || vol === 0) return '—';
|
||||||
|
const absVal = Math.abs(vol);
|
||||||
|
if (absVal >= 1e9) return `${(absVal / 1e9).toFixed(2)}B`;
|
||||||
|
if (absVal >= 1e6) return `${(absVal / 1e6).toFixed(2)}M`;
|
||||||
|
if (absVal >= 1e3) return `${(absVal / 1e3).toFixed(2)}K`;
|
||||||
|
return absVal.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format price
|
||||||
|
const formatPrice = (price) => {
|
||||||
|
if (!price || price === 0) return '—';
|
||||||
|
return `$${parseFloat(price).toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={idx} className="border-b border-slate-700/30 hover:bg-slate-800/70 transition-colors">
|
||||||
|
<td className="py-3 px-4 text-slate-300 font-medium">{dayLabel}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-200 font-mono">{formatVolume(day.volume)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-300 font-mono">{formatPrice(day.open)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-green-400 font-mono">{formatPrice(day.high)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-red-400 font-mono">{formatPrice(day.low)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-200 font-mono font-semibold">{formatPrice(day.close)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-blue-400 font-mono font-semibold">{formatPrice(day.vwap)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: market-app
|
||||||
|
namespace: ai-agents
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: market-app
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: market-app
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: market
|
||||||
|
image: 192.168.8.250:5000/market:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 3010
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: "production"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3010
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3010
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: market-app
|
||||||
|
namespace: ai-agents
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: market-app
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 3010
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: market-ingress
|
||||||
|
namespace: ai-agents
|
||||||
|
annotations:
|
||||||
|
k8s.apisix.apache.org/enable-websocket: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: apisix
|
||||||
|
rules:
|
||||||
|
- host: market.applaude.net
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: market-app
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
Loading…
Reference in New Issue