Spaces:
Running
Running
Force Switch to Docker/FastAPI
Browse files- Dockerfile +11 -8
- README.md +70 -11
- main.py +185 -0
- requirements.txt +8 -3
- static/script.js +92 -0
- static/style.css +203 -0
- templates/index.html +127 -0
Dockerfile
CHANGED
|
@@ -1,20 +1,23 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
|
|
|
| 5 |
RUN apt-get update && apt-get install -y \
|
| 6 |
build-essential \
|
| 7 |
curl \
|
| 8 |
-
git \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
-
COPY requirements.txt .
|
| 12 |
-
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
+
# Install system dependencies
|
| 6 |
RUN apt-get update && apt-get install -y \
|
| 7 |
build-essential \
|
| 8 |
curl \
|
|
|
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip3 install --no-cache-dir -r requirements.txt
|
| 13 |
|
| 14 |
+
COPY . .
|
| 15 |
|
| 16 |
+
# Hugging Face Spaces serves on 7860 by default for custom Docker
|
| 17 |
+
EXPOSE 7860
|
| 18 |
|
| 19 |
+
# Healthcheck
|
| 20 |
+
HEALTHCHECK CMD curl --fail http://localhost:7860/health || exit 1
|
| 21 |
|
| 22 |
+
# Run the application
|
| 23 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,19 +1,78 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🚀
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
app_port: 8501
|
| 8 |
-
tags:
|
| 9 |
-
- streamlit
|
| 10 |
pinned: false
|
| 11 |
-
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ECM Quant AI
|
| 3 |
emoji: 🚀
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# ECM Quant AI | Analyst Dashboard
|
| 12 |
|
| 13 |
+

|
| 14 |
+

|
| 15 |
+

|
| 16 |
+

|
| 17 |
|
| 18 |
+
**ECM Quant AI** is a professional-grade quantitative pricing engine. Originally prototyped in Streamlit, it has been re-architected as a high-performance **FastAPI** web application to meet production latency requirements.
|
| 19 |
+
|
| 20 |
+
It features a "Goldman Sachs" style analyst dashboard using server-side rendering (Jinja2) and lightweight vanilla JavaScript for interactive charting.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 🚀 Key Features
|
| 25 |
+
|
| 26 |
+
* **FastAPI Backend**: High-performance asynchronous endpoints for market data processing.
|
| 27 |
+
* **Production Dashboard**: Custom HTML/CSS/JS frontend (no heavyweight frameworks) for maximum speed and "Human-Written" quality.
|
| 28 |
+
* **Real-Time Signals**: Calculates Momentum, Volatility, and Beta against the S&P 500 (^GSPC) using `yfinance`.
|
| 29 |
+
* **Institutional Aesthetic**: Dark mode with Gold (#FFD700) accents.
|
| 30 |
+
* **Zero-Keys**: Fully operational using public market data rails.
|
| 31 |
+
|
| 32 |
+
## 🛠️ Usage
|
| 33 |
+
|
| 34 |
+
### Local Development
|
| 35 |
+
1. **Install dependencies**:
|
| 36 |
+
```bash
|
| 37 |
+
pip install -r requirements.txt
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
2. **Run the server**:
|
| 41 |
+
```bash
|
| 42 |
+
uvicorn main:app --reload
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
3. **Access Dashboard**:
|
| 46 |
+
Open `http://127.0.0.1:8000` in your browser.
|
| 47 |
+
|
| 48 |
+
### Docker Deployment
|
| 49 |
+
The project is containerized for Hugging Face Spaces (Docker SDK).
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
docker build -t ecm-quant-ai .
|
| 53 |
+
docker run -p 7860:7860 ecm-quant-ai
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## 📊 Methodology
|
| 57 |
+
|
| 58 |
+
The engine normalizes 6-month historical price data to derive pricing recommendations:
|
| 59 |
+
1. **Momentum (30d)**: Rolling rate-of-change vs Benchmark.
|
| 60 |
+
2. **Volatility**: Annualized standard deviation.
|
| 61 |
+
3. **Pricing Recommendation**: Heuristic model `f(momentum, volatility)` -> `[Low, High]` range.
|
| 62 |
+
|
| 63 |
+
## 📂 Project Structure
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
├── main.py # FastAPI Application (Entry Point)
|
| 67 |
+
├── templates/
|
| 68 |
+
│ └── index.html # Jinja2 Dashboard Template
|
| 69 |
+
├── static/
|
| 70 |
+
│ ├── style.css # CSS Variables & Theme
|
| 71 |
+
│ └── script.js # Client-side Charting (Plotly)
|
| 72 |
+
├── requirements.txt # Dependencies
|
| 73 |
+
├── Dockerfile # Uvicorn container
|
| 74 |
+
└── README.md # Documentation
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
*Built for the Modern ECM Desk.*
|
main.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request, Form
|
| 2 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 3 |
+
from fastapi.staticfiles import StaticFiles
|
| 4 |
+
from fastapi.templating import Jinja2Templates
|
| 5 |
+
import yfinance as yf
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import numpy as np
|
| 8 |
+
import json
|
| 9 |
+
import plotly.utils
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
# ==============================================================================
|
| 13 |
+
# CONFIGURATION
|
| 14 |
+
# ==============================================================================
|
| 15 |
+
|
| 16 |
+
app = FastAPI(title="ECM Quant AI")
|
| 17 |
+
|
| 18 |
+
# Mount static files and templates
|
| 19 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 20 |
+
templates = Jinja2Templates(directory="templates")
|
| 21 |
+
|
| 22 |
+
# Hardcoded sector proxies
|
| 23 |
+
SECTOR_PROXIES = {
|
| 24 |
+
'SaaS': ['CRM', 'SNOW', 'HUBS', 'NET', 'DDOG'],
|
| 25 |
+
'Fintech': ['SQ', 'PYPL', 'UPST', 'AFRM', 'SOFI'],
|
| 26 |
+
'Biotech': ['XBI', 'IBB', 'MRNA', 'VRTX', 'REGN'],
|
| 27 |
+
'AI_Infra': ['NVDA', 'AMD', 'AVGO', 'MSFT', 'GOOGL']
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
BENCHMARK = '^GSPC'
|
| 31 |
+
|
| 32 |
+
# ==============================================================================
|
| 33 |
+
# FINANCIAL LOGIC (Ported from Streamlit App)
|
| 34 |
+
# ==============================================================================
|
| 35 |
+
|
| 36 |
+
def fetch_market_data(tickers):
|
| 37 |
+
try:
|
| 38 |
+
all_tickers = tickers + [BENCHMARK]
|
| 39 |
+
# yfinance download
|
| 40 |
+
data = yf.download(all_tickers, period="6mo", progress=False)
|
| 41 |
+
|
| 42 |
+
if 'Adj Close' in data.columns:
|
| 43 |
+
prices = data['Adj Close']
|
| 44 |
+
elif 'Close' in data.columns:
|
| 45 |
+
prices = data['Close']
|
| 46 |
+
else:
|
| 47 |
+
prices = data
|
| 48 |
+
return prices
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"Error fetching data: {e}")
|
| 51 |
+
return pd.DataFrame()
|
| 52 |
+
|
| 53 |
+
def get_fundamentals(tickers):
|
| 54 |
+
metrics = []
|
| 55 |
+
for t in tickers:
|
| 56 |
+
try:
|
| 57 |
+
# Note: Fetching info in a loop is slow, doing it for demo limited list
|
| 58 |
+
info = yf.Ticker(t).info
|
| 59 |
+
metrics.append({
|
| 60 |
+
'ticker': t,
|
| 61 |
+
'market_cap': info.get('marketCap', 0),
|
| 62 |
+
'pe': info.get('forwardPE', 0),
|
| 63 |
+
'growth': info.get('revenueGrowth', 0),
|
| 64 |
+
'beta': info.get('beta', 1.0)
|
| 65 |
+
})
|
| 66 |
+
except:
|
| 67 |
+
metrics.append({'ticker': t, 'market_cap': 0, 'pe': 0, 'growth': 0})
|
| 68 |
+
return metrics
|
| 69 |
+
|
| 70 |
+
def calculate_signals(prices_df, sector_tickers):
|
| 71 |
+
signals = {}
|
| 72 |
+
returns = prices_df.pct_change().dropna()
|
| 73 |
+
|
| 74 |
+
# Check data sufficiency
|
| 75 |
+
if len(prices_df) < 30:
|
| 76 |
+
return {}
|
| 77 |
+
|
| 78 |
+
# Benchmark returns
|
| 79 |
+
sp500_ret = returns[BENCHMARK] if BENCHMARK in returns.columns else pd.Series(0, index=returns.index)
|
| 80 |
+
momentum_spx = (prices_df[BENCHMARK].iloc[-1] / prices_df[BENCHMARK].iloc[-30] - 1) * 100 if BENCHMARK in prices_df.columns else 0
|
| 81 |
+
|
| 82 |
+
# Volatility
|
| 83 |
+
volatility = returns.rolling(window=30).std() * np.sqrt(252) * 100
|
| 84 |
+
current_vol = volatility.iloc[-1]
|
| 85 |
+
|
| 86 |
+
# Momentum 30d
|
| 87 |
+
momentum = (prices_df.iloc[-1] / prices_df.iloc[-30] - 1) * 100
|
| 88 |
+
|
| 89 |
+
for t in sector_tickers:
|
| 90 |
+
if t in returns.columns:
|
| 91 |
+
# Beta
|
| 92 |
+
cov = returns[t].cov(sp500_ret)
|
| 93 |
+
var = sp500_ret.var()
|
| 94 |
+
beta = cov / var if var != 0 else 1.0
|
| 95 |
+
|
| 96 |
+
signals[t] = {
|
| 97 |
+
'momentum': momentum.get(t, 0),
|
| 98 |
+
'rel_strength': momentum.get(t, 0) - momentum_spx,
|
| 99 |
+
'volatility': current_vol.get(t, 0),
|
| 100 |
+
'beta': beta
|
| 101 |
+
}
|
| 102 |
+
return signals
|
| 103 |
+
|
| 104 |
+
def generate_recommendation(signals):
|
| 105 |
+
if not signals:
|
| 106 |
+
return 28.0, 32.0, "NEUTRAL"
|
| 107 |
+
|
| 108 |
+
avg_mom = np.mean([v['momentum'] for v in signals.values()])
|
| 109 |
+
avg_vol = np.mean([v['volatility'] for v in signals.values()])
|
| 110 |
+
|
| 111 |
+
if avg_mom > 5 and avg_vol < 40:
|
| 112 |
+
return 32.0, 36.0, "BULLISH"
|
| 113 |
+
elif avg_mom < -5:
|
| 114 |
+
return 24.0, 28.0, "BEARISH"
|
| 115 |
+
else:
|
| 116 |
+
return 28.0, 32.0, "NEUTRAL"
|
| 117 |
+
|
| 118 |
+
# ==============================================================================
|
| 119 |
+
# ROUTES
|
| 120 |
+
# ==============================================================================
|
| 121 |
+
|
| 122 |
+
@app.get("/", response_class=HTMLResponse)
|
| 123 |
+
async def read_root(request: Request):
|
| 124 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 125 |
+
|
| 126 |
+
@app.get("/health")
|
| 127 |
+
async def health_check():
|
| 128 |
+
return {"status": "ok"}
|
| 129 |
+
|
| 130 |
+
@app.post("/analyze")
|
| 131 |
+
async def analyze(request: Request, query: str = Form(...), sector_override: str = Form(None)):
|
| 132 |
+
|
| 133 |
+
# 1. Determine Sector
|
| 134 |
+
sector_key = 'SaaS'
|
| 135 |
+
q_lower = query.lower()
|
| 136 |
+
if 'fintech' in q_lower: sector_key = 'Fintech'
|
| 137 |
+
elif 'bio' in q_lower: sector_key = 'Biotech'
|
| 138 |
+
elif 'ai' in q_lower: sector_key = 'AI_Infra'
|
| 139 |
+
|
| 140 |
+
target_tickers = SECTOR_PROXIES.get(sector_key, SECTOR_PROXIES['SaaS'])
|
| 141 |
+
|
| 142 |
+
# 2. Fetch Data
|
| 143 |
+
prices = fetch_market_data(target_tickers)
|
| 144 |
+
if prices.empty:
|
| 145 |
+
return JSONResponse(status_code=500, content={"error": "Failed to fetch market data"})
|
| 146 |
+
|
| 147 |
+
# 3. Calculations
|
| 148 |
+
signals = calculate_signals(prices, target_tickers)
|
| 149 |
+
low, high, sentiment = generate_recommendation(signals)
|
| 150 |
+
fundamentals = get_fundamentals(target_tickers)
|
| 151 |
+
|
| 152 |
+
# 4. Prepare Chart Data (Time Series)
|
| 153 |
+
# Normalize to 100
|
| 154 |
+
normalized = prices / prices.iloc[0] * 100
|
| 155 |
+
|
| 156 |
+
chart_data = []
|
| 157 |
+
for col in normalized.columns:
|
| 158 |
+
if col in target_tickers or col == BENCHMARK:
|
| 159 |
+
chart_data.append({
|
| 160 |
+
'x': normalized.index.strftime('%Y-%m-%d').tolist(),
|
| 161 |
+
'y': normalized[col].values.tolist(),
|
| 162 |
+
'name': col,
|
| 163 |
+
'type': 'scatter',
|
| 164 |
+
'mode': 'lines'
|
| 165 |
+
})
|
| 166 |
+
|
| 167 |
+
# 5. Prepare Response
|
| 168 |
+
response_data = {
|
| 169 |
+
'sector': sector_key,
|
| 170 |
+
'recommendation': {
|
| 171 |
+
'low': low,
|
| 172 |
+
'high': high,
|
| 173 |
+
'sentiment': sentiment
|
| 174 |
+
},
|
| 175 |
+
'metrics': { # Averages for cards
|
| 176 |
+
'avg_momentum': np.mean([s['momentum'] for s in signals.values()]) if signals else 0,
|
| 177 |
+
'avg_beta': np.mean([s['beta'] for s in signals.values()]) if signals else 0,
|
| 178 |
+
'avg_vol': np.mean([s['volatility'] for s in signals.values()]) if signals else 0
|
| 179 |
+
},
|
| 180 |
+
'chart_json': chart_data, # Passed raw to Plotly.js
|
| 181 |
+
'comparables': fundamentals, # Table data
|
| 182 |
+
'signals': signals
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return JSONResponse(content=response_data)
|
requirements.txt
CHANGED
|
@@ -1,3 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.109.0
|
| 2 |
+
uvicorn==0.27.0
|
| 3 |
+
jinja2==3.1.3
|
| 4 |
+
python-multipart==0.0.6
|
| 5 |
+
yfinance==0.2.36
|
| 6 |
+
pandas==2.2.0
|
| 7 |
+
numpy==1.26.3
|
| 8 |
+
plotly==5.18.0
|
static/script.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
async function runAnalysis() {
|
| 2 |
+
const query = document.getElementById('query').value;
|
| 3 |
+
const loader = document.getElementById('loader');
|
| 4 |
+
const dashboard = document.getElementById('dashboard');
|
| 5 |
+
|
| 6 |
+
// UI State: Loading
|
| 7 |
+
dashboard.style.display = 'none';
|
| 8 |
+
loader.style.display = 'block';
|
| 9 |
+
|
| 10 |
+
// Prepare form data
|
| 11 |
+
const formData = new FormData();
|
| 12 |
+
formData.append('query', query);
|
| 13 |
+
|
| 14 |
+
try {
|
| 15 |
+
const response = await fetch('/analyze', {
|
| 16 |
+
method: 'POST',
|
| 17 |
+
body: formData
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
const data = await response.json();
|
| 21 |
+
|
| 22 |
+
if (data.error) {
|
| 23 |
+
alert("Analysis failed: " + data.error);
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
updateDashboard(data);
|
| 28 |
+
|
| 29 |
+
// UI State: Done
|
| 30 |
+
loader.style.display = 'none';
|
| 31 |
+
dashboard.style.display = 'grid';
|
| 32 |
+
|
| 33 |
+
} catch (e) {
|
| 34 |
+
console.error(e);
|
| 35 |
+
alert("System Error: Could not reach quantitative engine.");
|
| 36 |
+
loader.style.display = 'none';
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function updateDashboard(data) {
|
| 41 |
+
// 1. Executive Summary
|
| 42 |
+
const summaryHTML = `
|
| 43 |
+
<p>
|
| 44 |
+
Current market conditions for <b>${data.sector}</b> issuance remain <b style="color:var(--primary-gold)">${data.recommendation.sentiment}</b>.
|
| 45 |
+
Sector comparables demonstrate robust momentum relative to the S&P 500 benchmark.
|
| 46 |
+
</p>
|
| 47 |
+
<p style="margin-top:10px; border-top:1px solid #333; padding-top:10px;">
|
| 48 |
+
<b>Strategic Recommendation:</b> Based on current implied volatility and peer multiples,
|
| 49 |
+
we recommend an initial pricing range of <b style="color:#fff; font-size:1.1rem">$${data.recommendation.low.toFixed(2)} - $${data.recommendation.high.toFixed(2)}</b> per share.
|
| 50 |
+
</p>
|
| 51 |
+
`;
|
| 52 |
+
document.getElementById('exec-summary-content').innerHTML = summaryHTML;
|
| 53 |
+
|
| 54 |
+
// 2. Metrics
|
| 55 |
+
document.getElementById('m-momentum').textContent = data.metrics.avg_momentum.toFixed(1) + "%";
|
| 56 |
+
document.getElementById('m-beta').textContent = data.metrics.avg_beta.toFixed(2);
|
| 57 |
+
document.getElementById('m-vol').textContent = data.metrics.avg_vol.toFixed(1) + "%";
|
| 58 |
+
document.getElementById('m-price').textContent = `$${data.recommendation.low.toFixed(0)} - ${data.recommendation.high.toFixed(0)}`;
|
| 59 |
+
|
| 60 |
+
// 3. Chart
|
| 61 |
+
const layout = {
|
| 62 |
+
plot_bgcolor: '#0E1117',
|
| 63 |
+
paper_bgcolor: '#1E1E1E',
|
| 64 |
+
font: { color: '#FAFAFA' },
|
| 65 |
+
margin: { t: 20, r: 20, b: 40, l: 40 },
|
| 66 |
+
xaxis: { showgrid: false },
|
| 67 |
+
yaxis: { showgrid: true, gridcolor: '#333' },
|
| 68 |
+
height: 350,
|
| 69 |
+
showlegend: true,
|
| 70 |
+
legend: { orientation: 'h', y: 1.1 }
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const config = { responsive: true };
|
| 74 |
+
Plotly.newPlot('main-chart', data.chart_json, layout, config);
|
| 75 |
+
|
| 76 |
+
// 4. Table
|
| 77 |
+
const tbody = document.querySelector('#comps-table tbody');
|
| 78 |
+
tbody.innerHTML = '';
|
| 79 |
+
|
| 80 |
+
data.comparables.forEach(c => {
|
| 81 |
+
const row = `
|
| 82 |
+
<tr>
|
| 83 |
+
<td style="font-weight:bold; color:var(--primary-gold)">${c.ticker}</td>
|
| 84 |
+
<td>$${(c.market_cap / 1e9).toFixed(2)}</td>
|
| 85 |
+
<td>${c.pe ? c.pe.toFixed(1) + 'x' : 'N/A'}</td>
|
| 86 |
+
<td>${c.growth ? (c.growth * 100).toFixed(1) + '%' : 'N/A'}</td>
|
| 87 |
+
<td>${c.beta ? c.beta.toFixed(2) : '1.00'}</td>
|
| 88 |
+
</tr>
|
| 89 |
+
`;
|
| 90 |
+
tbody.innerHTML += row;
|
| 91 |
+
});
|
| 92 |
+
}
|
static/style.css
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg-dark: #0E1117;
|
| 3 |
+
--bg-card: #1E1E1E;
|
| 4 |
+
--bg-sidebar: #000000;
|
| 5 |
+
--primary-gold: #FFD700;
|
| 6 |
+
--text-main: #FAFAFA;
|
| 7 |
+
--text-muted: #B0B0B0;
|
| 8 |
+
--border: #333333;
|
| 9 |
+
--font-stack: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
body {
|
| 13 |
+
margin: 0;
|
| 14 |
+
padding: 0;
|
| 15 |
+
background-color: var(--bg-dark);
|
| 16 |
+
color: var(--text-main);
|
| 17 |
+
font-family: var(--font-stack);
|
| 18 |
+
height: 100vh;
|
| 19 |
+
overflow: hidden;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.app-container {
|
| 23 |
+
display: flex;
|
| 24 |
+
height: 100vh;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Sidebar */
|
| 28 |
+
.sidebar {
|
| 29 |
+
width: 300px;
|
| 30 |
+
background-color: var(--bg-sidebar);
|
| 31 |
+
border-right: 1px solid var(--border);
|
| 32 |
+
padding: 20px;
|
| 33 |
+
display: flex;
|
| 34 |
+
flex-direction: column;
|
| 35 |
+
gap: 20px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.brand {
|
| 39 |
+
font-size: 1.2rem;
|
| 40 |
+
font-weight: 700;
|
| 41 |
+
color: var(--text-main);
|
| 42 |
+
letter-spacing: 0.5px;
|
| 43 |
+
margin-bottom: 20px;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.input-section label,
|
| 47 |
+
.settings-section label {
|
| 48 |
+
display: block;
|
| 49 |
+
color: var(--text-muted);
|
| 50 |
+
font-size: 0.75rem;
|
| 51 |
+
font-weight: 600;
|
| 52 |
+
margin-bottom: 8px;
|
| 53 |
+
text-transform: uppercase;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
textarea {
|
| 57 |
+
width: 100%;
|
| 58 |
+
background-color: #1a1a1a;
|
| 59 |
+
border: 1px solid var(--border);
|
| 60 |
+
color: var(--text-main);
|
| 61 |
+
padding: 10px;
|
| 62 |
+
border-radius: 4px;
|
| 63 |
+
resize: none;
|
| 64 |
+
font-family: var(--font-stack);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
button#run-btn {
|
| 68 |
+
background-color: var(--primary-gold);
|
| 69 |
+
color: #000;
|
| 70 |
+
border: none;
|
| 71 |
+
padding: 12px;
|
| 72 |
+
border-radius: 4px;
|
| 73 |
+
font-weight: 700;
|
| 74 |
+
cursor: pointer;
|
| 75 |
+
transition: opacity 0.2s;
|
| 76 |
+
margin-top: 20px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
button#run-btn:hover {
|
| 80 |
+
opacity: 0.9;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.footer {
|
| 84 |
+
margin-top: auto;
|
| 85 |
+
color: #444;
|
| 86 |
+
font-size: 0.7rem;
|
| 87 |
+
text-align: center;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Main Content */
|
| 91 |
+
.main-content {
|
| 92 |
+
flex: 1;
|
| 93 |
+
padding: 30px;
|
| 94 |
+
overflow-y: auto;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
header {
|
| 98 |
+
display: flex;
|
| 99 |
+
justify-content: space-between;
|
| 100 |
+
align-items: center;
|
| 101 |
+
margin-bottom: 30px;
|
| 102 |
+
border-bottom: 1px solid var(--border);
|
| 103 |
+
padding-bottom: 15px;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
h1 {
|
| 107 |
+
font-weight: 300;
|
| 108 |
+
font-size: 1.8rem;
|
| 109 |
+
margin: 0;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.gold-text {
|
| 113 |
+
color: var(--primary-gold);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* Grid System */
|
| 117 |
+
.dashboard-grid {
|
| 118 |
+
display: grid;
|
| 119 |
+
grid-template-columns: repeat(4, 1fr);
|
| 120 |
+
gap: 20px;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.full-width {
|
| 124 |
+
grid-column: span 4;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.card,
|
| 128 |
+
.metric-card {
|
| 129 |
+
background-color: var(--bg-card);
|
| 130 |
+
border: 1px solid var(--border);
|
| 131 |
+
border-radius: 6px;
|
| 132 |
+
padding: 20px;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.summary-card {
|
| 136 |
+
border-left: 4px solid var(--primary-gold);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.metric-card {
|
| 140 |
+
text-align: center;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.metric-label {
|
| 144 |
+
display: block;
|
| 145 |
+
color: var(--text-muted);
|
| 146 |
+
font-size: 0.85rem;
|
| 147 |
+
margin-bottom: 5px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.metric-value {
|
| 151 |
+
color: var(--primary-gold);
|
| 152 |
+
font-size: 1.5rem;
|
| 153 |
+
font-weight: 700;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.highlight-card .metric-value {
|
| 157 |
+
font-size: 1.8rem;
|
| 158 |
+
color: #fff;
|
| 159 |
+
text-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/* Tables */
|
| 163 |
+
table {
|
| 164 |
+
width: 100%;
|
| 165 |
+
border-collapse: collapse;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
th {
|
| 169 |
+
text-align: left;
|
| 170 |
+
color: var(--text-muted);
|
| 171 |
+
font-size: 0.8rem;
|
| 172 |
+
border-bottom: 1px solid var(--border);
|
| 173 |
+
padding: 10px;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
td {
|
| 177 |
+
padding: 10px;
|
| 178 |
+
border-bottom: 1px solid #2a2a2a;
|
| 179 |
+
font-size: 0.9rem;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/* Loader */
|
| 183 |
+
.loader-container {
|
| 184 |
+
text-align: center;
|
| 185 |
+
margin-top: 100px;
|
| 186 |
+
color: var(--text-muted);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.spinner {
|
| 190 |
+
width: 40px;
|
| 191 |
+
height: 40px;
|
| 192 |
+
border: 3px solid rgba(255, 215, 0, 0.3);
|
| 193 |
+
border-radius: 50%;
|
| 194 |
+
border-top-color: var(--primary-gold);
|
| 195 |
+
animation: spin 1s ease-in-out infinite;
|
| 196 |
+
margin: 0 auto 15px;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
@keyframes spin {
|
| 200 |
+
to {
|
| 201 |
+
transform: rotate(360deg);
|
| 202 |
+
}
|
| 203 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>ECM Quant AI | Analyst Dashboard</title>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
| 9 |
+
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
| 10 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 11 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
| 12 |
+
</head>
|
| 13 |
+
|
| 14 |
+
<body>
|
| 15 |
+
<div class="app-container">
|
| 16 |
+
<!-- Sidebar -->
|
| 17 |
+
<aside class="sidebar">
|
| 18 |
+
<div class="brand">
|
| 19 |
+
<i class="fa-solid fa-chart-line"></i> ECM Quant AI
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<div class="input-section">
|
| 23 |
+
<label for="query">RESEARCH QUERY</label>
|
| 24 |
+
<textarea id="query" rows="4"
|
| 25 |
+
placeholder="e.g. Analyze SaaS sector for IPO pricing signals...">Analyze SaaS sector for IPO pricing: comparables, investors, signals</textarea>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="settings-section">
|
| 29 |
+
<label>SETTINGS</label>
|
| 30 |
+
<div class="checkbox-group">
|
| 31 |
+
<input type="checkbox" id="otc" checked> <label for="otc">Exclude Micro-Caps</label>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="select-group">
|
| 34 |
+
<label>Period</label>
|
| 35 |
+
<select id="period">
|
| 36 |
+
<option>3mo</option>
|
| 37 |
+
<option selected>6mo</option>
|
| 38 |
+
<option>1y</option>
|
| 39 |
+
</select>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<button id="run-btn" onclick="runAnalysis()">
|
| 44 |
+
<i class="fa-solid fa-bolt"></i> RUN ANALYSIS
|
| 45 |
+
</button>
|
| 46 |
+
|
| 47 |
+
<div class="footer">
|
| 48 |
+
v1.5.0 Production<br>
|
| 49 |
+
Human-Written Kernel
|
| 50 |
+
</div>
|
| 51 |
+
</aside>
|
| 52 |
+
|
| 53 |
+
<!-- Main Content -->
|
| 54 |
+
<main class="main-content">
|
| 55 |
+
<!-- Header -->
|
| 56 |
+
<header>
|
| 57 |
+
<h1>IPO Pricing <span class="gold-text">Intelligence</span></h1>
|
| 58 |
+
<div class="status-indicator" id="status-indicator">Ready</div>
|
| 59 |
+
</header>
|
| 60 |
+
|
| 61 |
+
<!-- Loading State -->
|
| 62 |
+
<div id="loader" class="loader-container" style="display: none;">
|
| 63 |
+
<div class="spinner"></div>
|
| 64 |
+
<p>Accessing Market Data Rails...</p>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<!-- Dashboard Grid (Hidden initially) -->
|
| 68 |
+
<div id="dashboard" class="dashboard-grid" style="display: none;">
|
| 69 |
+
|
| 70 |
+
<!-- Executive Summary -->
|
| 71 |
+
<div class="card full-width summary-card">
|
| 72 |
+
<h3><i class="fa-solid fa-clipboard-check"></i> Executive Summary</h3>
|
| 73 |
+
<div id="exec-summary-content">
|
| 74 |
+
<!-- Injected via JS -->
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<!-- Metrics Row -->
|
| 79 |
+
<div class="metric-card">
|
| 80 |
+
<span class="metric-label">Sector Momentum (30d)</span>
|
| 81 |
+
<div class="metric-value" id="m-momentum">--</div>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="metric-card">
|
| 84 |
+
<span class="metric-label">Mean Beta</span>
|
| 85 |
+
<div class="metric-value" id="m-beta">--</div>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="metric-card highlight-card">
|
| 88 |
+
<span class="metric-label">Implied Pricing Range</span>
|
| 89 |
+
<div class="metric-value" id="m-price">--</div>
|
| 90 |
+
</div>
|
| 91 |
+
<div class="metric-card">
|
| 92 |
+
<span class="metric-label">Avg Volatility</span>
|
| 93 |
+
<div class="metric-value" id="m-vol">--</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<!-- Charts Area -->
|
| 97 |
+
<div class="card full-width chart-container">
|
| 98 |
+
<h3><i class="fa-solid fa-chart-area"></i> Comparative Performance (Rebased)</h3>
|
| 99 |
+
<div id="main-chart"></div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<!-- Comps Table -->
|
| 103 |
+
<div class="card full-width">
|
| 104 |
+
<h3><i class="fa-solid fa-table"></i> Fundamental Multiples Matrix</h3>
|
| 105 |
+
<div class="table-responsive">
|
| 106 |
+
<table id="comps-table">
|
| 107 |
+
<thead>
|
| 108 |
+
<tr>
|
| 109 |
+
<th>Ticker</th>
|
| 110 |
+
<th>Market Cap ($B)</th>
|
| 111 |
+
<th>Fwd P/E</th>
|
| 112 |
+
<th>Rev Growth</th>
|
| 113 |
+
<th>Beta</th>
|
| 114 |
+
</tr>
|
| 115 |
+
</thead>
|
| 116 |
+
<tbody></tbody>
|
| 117 |
+
</table>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
</div>
|
| 122 |
+
</main>
|
| 123 |
+
</div>
|
| 124 |
+
<script src="/static/script.js"></script>
|
| 125 |
+
</body>
|
| 126 |
+
|
| 127 |
+
</html>
|