Commit ·
60ccd0b
1
Parent(s): 56f2c9e
Initial backend deployment
Browse files- .gitignore +6 -0
- Dockerfile +12 -0
- __init__.py +0 -0
- app/.DS_Store +0 -0
- app/config.py +7 -0
- app/main.py +42 -0
- app/ml/__init__.py +0 -0
- app/ml/backtest.py +75 -0
- app/ml/predict.py +62 -0
- app/ml/train.py +119 -0
- app/routers/__init__.py +0 -0
- app/routers/backtest.py +16 -0
- app/routers/prediction.py +18 -0
- app/routers/sentiment.py +12 -0
- app/routers/stock.py +64 -0
- app/services/__init__.py +0 -0
- app/services/data_fetcher.py +72 -0
- app/services/decision_engine.py +96 -0
- app/services/indicators.py +148 -0
- app/services/sentiment.py +228 -0
- requirements.txt +83 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
| 4 |
+
venv/
|
| 5 |
+
*.h5
|
| 6 |
+
*.pkl
|
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
__init__.py
ADDED
|
File without changes
|
app/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
app/config.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dotenv import load_dotenv
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
APP_NAME = os.getenv("APP_NAME", "AlphaSignal")
|
| 7 |
+
DEBUG = os.getenv("DEBUG", "True") == "True"
|
app/main.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from app.config import APP_NAME
|
| 4 |
+
from app.routers import stock
|
| 5 |
+
from app.routers import prediction
|
| 6 |
+
from app.routers import sentiment
|
| 7 |
+
|
| 8 |
+
app = FastAPI(
|
| 9 |
+
title=APP_NAME,
|
| 10 |
+
description="AI-powered Stock Prediction & Decision Support System",
|
| 11 |
+
version="1.0.0"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
# Allow React frontend to talk to this backend
|
| 15 |
+
app.add_middleware(
|
| 16 |
+
CORSMiddleware,
|
| 17 |
+
allow_origins=["http://localhost:5173"], # React dev server
|
| 18 |
+
allow_credentials=True,
|
| 19 |
+
allow_methods=["*"],
|
| 20 |
+
allow_headers=["*"],
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# Register routers
|
| 24 |
+
app.include_router(stock.router) # ← ADD THIS
|
| 25 |
+
|
| 26 |
+
@app.get("/")
|
| 27 |
+
def root():
|
| 28 |
+
return {
|
| 29 |
+
"app": APP_NAME,
|
| 30 |
+
"status": "AlphaSignal is running 🚀",
|
| 31 |
+
"version": "1.0.0"
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
@app.get("/health")
|
| 35 |
+
def health():
|
| 36 |
+
return {"status": "healthy"}
|
| 37 |
+
|
| 38 |
+
app.include_router(prediction.router)
|
| 39 |
+
app.include_router(sentiment.router)
|
| 40 |
+
|
| 41 |
+
from app.routers import backtest
|
| 42 |
+
app.include_router(backtest.router)
|
app/ml/__init__.py
ADDED
|
File without changes
|
app/ml/backtest.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pickle
|
| 3 |
+
from tensorflow.keras.models import load_model
|
| 4 |
+
|
| 5 |
+
from .train import (
|
| 6 |
+
get_cache_paths, is_cached, train_and_cache,
|
| 7 |
+
download_and_engineer, SEQ_LEN, N_FEATURES
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def run_backtest(ticker: str):
|
| 12 |
+
if is_cached(ticker):
|
| 13 |
+
mp, sp, fp = get_cache_paths(ticker)
|
| 14 |
+
model = load_model(mp)
|
| 15 |
+
with open(sp, "rb") as f:
|
| 16 |
+
close_scaler = pickle.load(f)
|
| 17 |
+
with open(fp, "rb") as f:
|
| 18 |
+
feat_scaler = pickle.load(f)
|
| 19 |
+
df = download_and_engineer(ticker)
|
| 20 |
+
else:
|
| 21 |
+
model, close_scaler, feat_scaler, df = train_and_cache(ticker)
|
| 22 |
+
|
| 23 |
+
feat_cols = ["Close", "Volume", "RSI", "MACD", "MACD_Signal"]
|
| 24 |
+
scaled_features = feat_scaler.transform(df[feat_cols].values)
|
| 25 |
+
actual_prices = df["Close"].values.flatten()
|
| 26 |
+
actual_dates = df.index
|
| 27 |
+
|
| 28 |
+
results = []
|
| 29 |
+
for i in range(30, 0, -1):
|
| 30 |
+
end_idx = len(scaled_features) - i
|
| 31 |
+
if end_idx < SEQ_LEN:
|
| 32 |
+
continue
|
| 33 |
+
|
| 34 |
+
inp = scaled_features[end_idx - SEQ_LEN:end_idx].reshape(1, SEQ_LEN, N_FEATURES)
|
| 35 |
+
pred_scaled = model.predict(inp, verbose=0)[0][0]
|
| 36 |
+
pred_price = close_scaler.inverse_transform([[pred_scaled]])[0][0]
|
| 37 |
+
|
| 38 |
+
results.append({
|
| 39 |
+
"date": actual_dates[end_idx].strftime("%Y-%m-%d"),
|
| 40 |
+
"actual": round(float(actual_prices[end_idx]), 2),
|
| 41 |
+
"predicted": round(float(pred_price), 2),
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
if not results:
|
| 45 |
+
raise ValueError("Not enough data for backtest")
|
| 46 |
+
|
| 47 |
+
actuals = np.array([r["actual"] for r in results])
|
| 48 |
+
preds = np.array([r["predicted"] for r in results])
|
| 49 |
+
|
| 50 |
+
rmse = round(float(np.sqrt(np.mean((actuals - preds) ** 2))), 2)
|
| 51 |
+
mae = round(float(np.mean(np.abs(actuals - preds))), 2)
|
| 52 |
+
mape = round(float(np.mean(np.abs((actuals - preds) / actuals)) * 100), 2)
|
| 53 |
+
|
| 54 |
+
correct_dir = sum(
|
| 55 |
+
1 for i in range(1, len(results))
|
| 56 |
+
if (results[i]["actual"] - results[i-1]["actual"]) *
|
| 57 |
+
(results[i]["predicted"] - results[i-1]["predicted"]) > 0
|
| 58 |
+
)
|
| 59 |
+
directional_accuracy = round(correct_dir / (len(results) - 1) * 100, 1) if len(results) > 1 else 0
|
| 60 |
+
|
| 61 |
+
verdict = (
|
| 62 |
+
"good" if directional_accuracy >= 60 and mape <= 2 else
|
| 63 |
+
"moderate" if directional_accuracy >= 50 and mape <= 5 else
|
| 64 |
+
"poor"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"ticker": ticker,
|
| 69 |
+
"rmse": rmse,
|
| 70 |
+
"mae": mae,
|
| 71 |
+
"mape": mape,
|
| 72 |
+
"directional_accuracy": directional_accuracy,
|
| 73 |
+
"verdict": verdict,
|
| 74 |
+
"data_points": results,
|
| 75 |
+
}
|
app/ml/predict.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import pickle
|
| 4 |
+
from tensorflow.keras.models import load_model
|
| 5 |
+
|
| 6 |
+
from .train import (
|
| 7 |
+
get_cache_paths, is_cached, train_and_cache,
|
| 8 |
+
download_and_engineer, SEQ_LEN, N_FEATURES
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _load_artifacts(ticker: str):
|
| 13 |
+
if is_cached(ticker):
|
| 14 |
+
mp, sp, fp = get_cache_paths(ticker)
|
| 15 |
+
model = load_model(mp)
|
| 16 |
+
with open(sp, "rb") as f:
|
| 17 |
+
close_scaler = pickle.load(f)
|
| 18 |
+
with open(fp, "rb") as f:
|
| 19 |
+
feat_scaler = pickle.load(f)
|
| 20 |
+
df = download_and_engineer(ticker)
|
| 21 |
+
else:
|
| 22 |
+
model, close_scaler, feat_scaler, df = train_and_cache(ticker)
|
| 23 |
+
return model, close_scaler, feat_scaler, df
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_predictions(ticker: str, sentiment_score: float = 0.0):
|
| 27 |
+
"""
|
| 28 |
+
Predict 7 business days ahead.
|
| 29 |
+
sentiment_score: float [-1, 1] from FinBERT (0.0 = ignore)
|
| 30 |
+
"""
|
| 31 |
+
model, close_scaler, feat_scaler, df = _load_artifacts(ticker)
|
| 32 |
+
|
| 33 |
+
feat_cols = ["Close", "Volume", "RSI", "MACD", "MACD_Signal"]
|
| 34 |
+
scaled_features = feat_scaler.transform(df[feat_cols].values)
|
| 35 |
+
|
| 36 |
+
current_window = scaled_features[-SEQ_LEN:].copy()
|
| 37 |
+
last_aux = scaled_features[-1, 1:].copy() # Volume, RSI, MACD, Signal
|
| 38 |
+
raw_preds = []
|
| 39 |
+
|
| 40 |
+
for _ in range(7):
|
| 41 |
+
inp = current_window[-SEQ_LEN:].reshape(1, SEQ_LEN, N_FEATURES)
|
| 42 |
+
pred = model.predict(inp, verbose=0)[0][0]
|
| 43 |
+
raw_preds.append(pred)
|
| 44 |
+
current_window = np.vstack([current_window, np.concatenate([[pred], last_aux])])
|
| 45 |
+
|
| 46 |
+
predicted_prices = close_scaler.inverse_transform(
|
| 47 |
+
np.array(raw_preds).reshape(-1, 1)
|
| 48 |
+
).flatten()
|
| 49 |
+
|
| 50 |
+
# Gentle sentiment nudge (max ±1.5%, decays over 7 days)
|
| 51 |
+
if sentiment_score != 0.0:
|
| 52 |
+
for i in range(len(predicted_prices)):
|
| 53 |
+
decay = 1 - (i / len(predicted_prices))
|
| 54 |
+
predicted_prices[i] *= (1 + sentiment_score * 0.015 * decay)
|
| 55 |
+
|
| 56 |
+
last_date = df.index[-1]
|
| 57 |
+
future_dates = pd.bdate_range(start=last_date + pd.Timedelta(days=1), periods=7)
|
| 58 |
+
|
| 59 |
+
return [
|
| 60 |
+
{"date": d.strftime("%Y-%m-%d"), "predicted_price": round(float(p), 2)}
|
| 61 |
+
for d, p in zip(future_dates, predicted_prices)
|
| 62 |
+
]
|
app/ml/train.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import yfinance as yf
|
| 4 |
+
import pickle
|
| 5 |
+
import os
|
| 6 |
+
from datetime import date
|
| 7 |
+
from sklearn.preprocessing import MinMaxScaler
|
| 8 |
+
from tensorflow.keras.models import Sequential
|
| 9 |
+
from tensorflow.keras.layers import LSTM, Dense, Dropout, BatchNormalization
|
| 10 |
+
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau
|
| 11 |
+
from tensorflow.keras.optimizers import Adam
|
| 12 |
+
|
| 13 |
+
# ── Constants ────────────────────────────────────────────────────────────────
|
| 14 |
+
SEQ_LEN = 120 # 2x original (60), still lightweight
|
| 15 |
+
N_FEATURES = 5 # Close, Volume, RSI, MACD, MACD_Signal
|
| 16 |
+
DATA_PERIOD = "3y" # good balance of history vs. download/training time
|
| 17 |
+
CACHE_DIR = os.path.join(os.path.dirname(__file__), "..", "cache", "models")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ── Cache helpers (daily key so model auto-refreshes each day) ───────────────
|
| 21 |
+
def get_cache_paths(ticker):
|
| 22 |
+
safe = ticker.replace(".", "_")
|
| 23 |
+
today = date.today().strftime("%Y%m%d")
|
| 24 |
+
base = os.path.join(CACHE_DIR, f"{safe}_{today}")
|
| 25 |
+
return base + "_model.keras", base + "_scaler.pkl", base + "_feat_scaler.pkl"
|
| 26 |
+
|
| 27 |
+
def is_cached(ticker):
|
| 28 |
+
mp, sp, fp = get_cache_paths(ticker)
|
| 29 |
+
return os.path.exists(mp) and os.path.exists(sp) and os.path.exists(fp)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ── Feature engineering ──────────────────────────────────────────────────────
|
| 33 |
+
def add_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 34 |
+
close = df["Close"]
|
| 35 |
+
|
| 36 |
+
delta = close.diff()
|
| 37 |
+
gain = delta.clip(lower=0).rolling(14).mean()
|
| 38 |
+
loss = (-delta.clip(upper=0)).rolling(14).mean()
|
| 39 |
+
df["RSI"] = 100 - (100 / (1 + gain / (loss + 1e-9)))
|
| 40 |
+
|
| 41 |
+
ema12 = close.ewm(span=12, adjust=False).mean()
|
| 42 |
+
ema26 = close.ewm(span=26, adjust=False).mean()
|
| 43 |
+
df["MACD"] = ema12 - ema26
|
| 44 |
+
df["MACD_Signal"] = df["MACD"].ewm(span=9, adjust=False).mean()
|
| 45 |
+
|
| 46 |
+
return df.dropna()
|
| 47 |
+
|
| 48 |
+
def download_and_engineer(ticker: str) -> pd.DataFrame:
|
| 49 |
+
raw = yf.download(ticker, period=DATA_PERIOD, interval="1d", auto_adjust=True)
|
| 50 |
+
df = raw[["Close", "Volume"]].copy()
|
| 51 |
+
return add_features(df)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ── Sequence builder ─────────────────────────────────────────────────────────
|
| 55 |
+
def build_sequences(scaled_features, scaled_close):
|
| 56 |
+
X, y = [], []
|
| 57 |
+
for i in range(SEQ_LEN, len(scaled_features)):
|
| 58 |
+
X.append(scaled_features[i - SEQ_LEN:i])
|
| 59 |
+
y.append(scaled_close[i, 0])
|
| 60 |
+
return np.array(X), np.array(y)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ── Lightweight 2-layer model (M4 Air friendly) ──────────────────────────────
|
| 64 |
+
def build_model():
|
| 65 |
+
model = Sequential([
|
| 66 |
+
LSTM(128, return_sequences=True, input_shape=(SEQ_LEN, N_FEATURES)),
|
| 67 |
+
BatchNormalization(),
|
| 68 |
+
Dropout(0.2),
|
| 69 |
+
|
| 70 |
+
LSTM(64, return_sequences=False),
|
| 71 |
+
BatchNormalization(),
|
| 72 |
+
Dropout(0.2),
|
| 73 |
+
|
| 74 |
+
Dense(32, activation="relu"),
|
| 75 |
+
Dense(1)
|
| 76 |
+
])
|
| 77 |
+
model.compile(optimizer=Adam(learning_rate=1e-3), loss="huber")
|
| 78 |
+
return model
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ── Train + cache ────────────────────────────────────────────────────────────
|
| 82 |
+
def train_and_cache(ticker: str):
|
| 83 |
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
| 84 |
+
|
| 85 |
+
df = download_and_engineer(ticker)
|
| 86 |
+
if len(df) < SEQ_LEN + 50:
|
| 87 |
+
raise ValueError(f"Not enough data for {ticker}")
|
| 88 |
+
|
| 89 |
+
feat_cols = ["Close", "Volume", "RSI", "MACD", "MACD_Signal"]
|
| 90 |
+
feat_scaler = MinMaxScaler()
|
| 91 |
+
close_scaler = MinMaxScaler()
|
| 92 |
+
|
| 93 |
+
scaled_features = feat_scaler.fit_transform(df[feat_cols].values)
|
| 94 |
+
scaled_close = close_scaler.fit_transform(df[["Close"]].values)
|
| 95 |
+
|
| 96 |
+
X, y = build_sequences(scaled_features, scaled_close)
|
| 97 |
+
split = int(len(X) * 0.8)
|
| 98 |
+
|
| 99 |
+
model = build_model()
|
| 100 |
+
model.fit(
|
| 101 |
+
X[:split], y[:split],
|
| 102 |
+
epochs=50, # reduced from 100
|
| 103 |
+
batch_size=64, # larger batch = fewer steps = faster
|
| 104 |
+
validation_data=(X[split:], y[split:]),
|
| 105 |
+
callbacks=[
|
| 106 |
+
EarlyStopping(monitor="val_loss", patience=8, restore_best_weights=True),
|
| 107 |
+
ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=4, min_lr=1e-5),
|
| 108 |
+
],
|
| 109 |
+
verbose=0,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
model_path, scaler_path, feat_scaler_path = get_cache_paths(ticker)
|
| 113 |
+
model.save(model_path)
|
| 114 |
+
with open(scaler_path, "wb") as f:
|
| 115 |
+
pickle.dump(close_scaler, f)
|
| 116 |
+
with open(feat_scaler_path, "wb") as f:
|
| 117 |
+
pickle.dump(feat_scaler, f)
|
| 118 |
+
|
| 119 |
+
return model, close_scaler, feat_scaler, df
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/backtest.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from app.ml.backtest import run_backtest
|
| 3 |
+
import traceback
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
@router.get("/api/stock/{ticker}/backtest")
|
| 8 |
+
def backtest_stock(ticker: str):
|
| 9 |
+
try:
|
| 10 |
+
result = run_backtest(ticker)
|
| 11 |
+
return result
|
| 12 |
+
except ValueError as e:
|
| 13 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 14 |
+
except Exception as e:
|
| 15 |
+
print("BACKTEST ERROR:", traceback.format_exc())
|
| 16 |
+
raise HTTPException(status_code=500, detail=f"Backtest failed: {str(e)}")
|
app/routers/prediction.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from app.ml.predict import get_predictions
|
| 3 |
+
|
| 4 |
+
router = APIRouter()
|
| 5 |
+
|
| 6 |
+
@router.get("/api/stock/{ticker}/predict")
|
| 7 |
+
def predict_stock(ticker: str):
|
| 8 |
+
try:
|
| 9 |
+
predictions = get_predictions(ticker)
|
| 10 |
+
return {
|
| 11 |
+
"ticker": ticker,
|
| 12 |
+
"predictions": predictions,
|
| 13 |
+
"days": 7
|
| 14 |
+
}
|
| 15 |
+
except ValueError as e:
|
| 16 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 17 |
+
except Exception as e:
|
| 18 |
+
raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}")
|
app/routers/sentiment.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from app.services.sentiment import get_sentiment_score
|
| 3 |
+
|
| 4 |
+
router = APIRouter()
|
| 5 |
+
|
| 6 |
+
@router.get("/api/stock/{ticker}/sentiment")
|
| 7 |
+
def get_sentiment(ticker: str):
|
| 8 |
+
try:
|
| 9 |
+
result = get_sentiment_score(ticker)
|
| 10 |
+
return result
|
| 11 |
+
except Exception as e:
|
| 12 |
+
raise HTTPException(status_code=500, detail=f"Sentiment analysis failed: {str(e)}")
|
app/routers/stock.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from app.services.data_fetcher import get_stock_history, get_stock_info
|
| 3 |
+
from app.services.indicators import get_all_indicators
|
| 4 |
+
from app.services.decision_engine import get_decision
|
| 5 |
+
|
| 6 |
+
router = APIRouter(
|
| 7 |
+
prefix="/api/stock",
|
| 8 |
+
tags=["Stock Data"]
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
@router.get("/{ticker}/history")
|
| 12 |
+
def stock_history(ticker: str, period: str = "1y"):
|
| 13 |
+
data = get_stock_history(ticker.upper(), period)
|
| 14 |
+
if data is None:
|
| 15 |
+
raise HTTPException(status_code=404, detail=f"Stock '{ticker}' not found or no data available")
|
| 16 |
+
return {
|
| 17 |
+
"ticker": ticker.upper(),
|
| 18 |
+
"period": period,
|
| 19 |
+
"count": len(data),
|
| 20 |
+
"data": data
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/{ticker}/info")
|
| 25 |
+
def stock_info(ticker: str):
|
| 26 |
+
data = get_stock_info(ticker.upper())
|
| 27 |
+
if data is None:
|
| 28 |
+
raise HTTPException(status_code=404, detail=f"Stock '{ticker}' not found")
|
| 29 |
+
return data
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/{ticker}/indicators")
|
| 33 |
+
def stock_indicators(ticker: str, period: str = "1y"):
|
| 34 |
+
data = get_all_indicators(ticker.upper(), period)
|
| 35 |
+
if data is None:
|
| 36 |
+
raise HTTPException(status_code=404, detail=f"Could not calculate indicators for '{ticker}'")
|
| 37 |
+
return data
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/{ticker}/decision")
|
| 41 |
+
def stock_decision(ticker: str):
|
| 42 |
+
data = get_decision(ticker.upper())
|
| 43 |
+
if data is None:
|
| 44 |
+
raise HTTPException(status_code=404, detail=f"Could not generate decision for '{ticker}'")
|
| 45 |
+
return data
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.get("/{ticker}/summary")
|
| 49 |
+
def stock_summary(ticker: str, period: str = "1y"):
|
| 50 |
+
ticker = ticker.upper()
|
| 51 |
+
decision_data = get_decision(ticker, period)
|
| 52 |
+
if decision_data is None:
|
| 53 |
+
raise HTTPException(status_code=404, detail=f"Could not generate summary for '{ticker}'")
|
| 54 |
+
info_data = get_stock_info(ticker)
|
| 55 |
+
return {
|
| 56 |
+
"ticker": ticker,
|
| 57 |
+
"info": info_data,
|
| 58 |
+
"decision": decision_data["decision"],
|
| 59 |
+
"confidence": decision_data["confidence"],
|
| 60 |
+
"score": decision_data["score"],
|
| 61 |
+
"risk": decision_data["risk"],
|
| 62 |
+
"reasons": decision_data["reasons"],
|
| 63 |
+
"indicators": decision_data["latest"]
|
| 64 |
+
}
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/data_fetcher.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import yfinance as yf
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
def get_stock_history(ticker: str, period: str = "1y", interval: str = None):
|
| 5 |
+
try:
|
| 6 |
+
stock = yf.Ticker(ticker)
|
| 7 |
+
|
| 8 |
+
if interval is None:
|
| 9 |
+
if period == "1d":
|
| 10 |
+
interval = "5m"
|
| 11 |
+
elif period == "5d":
|
| 12 |
+
interval = "1h"
|
| 13 |
+
elif period == "1mo":
|
| 14 |
+
interval = "1h"
|
| 15 |
+
else:
|
| 16 |
+
interval = "1d"
|
| 17 |
+
|
| 18 |
+
if period == "1d":
|
| 19 |
+
df = stock.history(period="1d", interval="5m")
|
| 20 |
+
else:
|
| 21 |
+
df = stock.history(period=period, interval=interval)
|
| 22 |
+
|
| 23 |
+
if df.empty:
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
df = df.reset_index()
|
| 27 |
+
|
| 28 |
+
date_col = "Datetime" if "Datetime" in df.columns else "Date"
|
| 29 |
+
df = df.rename(columns={date_col: "Date"})
|
| 30 |
+
|
| 31 |
+
df["Date"] = pd.to_datetime(df["Date"])
|
| 32 |
+
|
| 33 |
+
if hasattr(df["Date"].dt, "tz") and df["Date"].dt.tz is not None:
|
| 34 |
+
df["Date"] = df["Date"].dt.tz_localize(None)
|
| 35 |
+
|
| 36 |
+
if period == "1d":
|
| 37 |
+
df["Date"] = df["Date"].dt.strftime("%H:%M")
|
| 38 |
+
elif period in ["5d", "1mo"]:
|
| 39 |
+
df["Date"] = df["Date"].dt.strftime("%d %b %H:%M")
|
| 40 |
+
else:
|
| 41 |
+
df["Date"] = df["Date"].dt.strftime("%Y-%m-%d")
|
| 42 |
+
|
| 43 |
+
df = df[["Date", "Open", "High", "Low", "Close", "Volume"]]
|
| 44 |
+
df = df.round(2)
|
| 45 |
+
|
| 46 |
+
return df.to_dict(orient="records")
|
| 47 |
+
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"Error fetching history for {ticker}: {e}")
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def get_stock_info(ticker: str):
|
| 54 |
+
try:
|
| 55 |
+
stock = yf.Ticker(ticker)
|
| 56 |
+
info = stock.info
|
| 57 |
+
|
| 58 |
+
return {
|
| 59 |
+
"ticker": ticker,
|
| 60 |
+
"name": info.get("longName", ticker),
|
| 61 |
+
"sector": info.get("sector", "N/A"),
|
| 62 |
+
"current_price": info.get("currentPrice", info.get("regularMarketPrice", 0)),
|
| 63 |
+
"currency": info.get("currency", "USD"),
|
| 64 |
+
"market_cap": info.get("marketCap", 0),
|
| 65 |
+
"pe_ratio": info.get("trailingPE", 0),
|
| 66 |
+
"52_week_high": info.get("fiftyTwoWeekHigh", 0),
|
| 67 |
+
"52_week_low": info.get("fiftyTwoWeekLow", 0),
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"Error fetching info for {ticker}: {e}")
|
| 72 |
+
return None
|
app/services/decision_engine.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.indicators import get_all_indicators
|
| 2 |
+
|
| 3 |
+
def get_decision(ticker: str, period: str = "1y"):
|
| 4 |
+
data = get_all_indicators(ticker, period)
|
| 5 |
+
|
| 6 |
+
if data is None:
|
| 7 |
+
return None
|
| 8 |
+
|
| 9 |
+
latest = data["latest"]
|
| 10 |
+
score = 0
|
| 11 |
+
reasons = []
|
| 12 |
+
|
| 13 |
+
rsi = latest["rsi"]
|
| 14 |
+
macd_line = latest["macd_line"]
|
| 15 |
+
signal_line = latest["signal_line"]
|
| 16 |
+
histogram = latest["histogram"]
|
| 17 |
+
close = latest["close"]
|
| 18 |
+
upper_band = latest["upper_band"]
|
| 19 |
+
lower_band = latest["lower_band"]
|
| 20 |
+
middle_band = latest["middle_band"]
|
| 21 |
+
sma20 = latest["sma20"]
|
| 22 |
+
sma50 = latest["sma50"]
|
| 23 |
+
|
| 24 |
+
if rsi is not None:
|
| 25 |
+
if rsi < 30:
|
| 26 |
+
score += 2
|
| 27 |
+
reasons.append(f"RSI is {rsi} — stock is oversold, potential BUY opportunity")
|
| 28 |
+
elif rsi > 70:
|
| 29 |
+
score -= 2
|
| 30 |
+
reasons.append(f"RSI is {rsi} — stock is overbought, potential SELL signal")
|
| 31 |
+
else:
|
| 32 |
+
reasons.append(f"RSI is {rsi} — stock is in neutral zone")
|
| 33 |
+
|
| 34 |
+
if histogram is not None and macd_line is not None and signal_line is not None:
|
| 35 |
+
if histogram > 0 and macd_line > signal_line:
|
| 36 |
+
score += 2
|
| 37 |
+
reasons.append(f"MACD is bullish — momentum is increasing, BUY signal")
|
| 38 |
+
elif histogram < 0 and macd_line < signal_line:
|
| 39 |
+
score -= 2
|
| 40 |
+
reasons.append(f"MACD is bearish — momentum is decreasing, SELL signal")
|
| 41 |
+
else:
|
| 42 |
+
reasons.append(f"MACD is neutral — no clear momentum signal")
|
| 43 |
+
|
| 44 |
+
if close is not None and sma20 is not None and sma50 is not None:
|
| 45 |
+
if close > sma20 and close > sma50:
|
| 46 |
+
score += 2
|
| 47 |
+
reasons.append(f"Price is above SMA20 and SMA50 — strong uptrend, bullish signal")
|
| 48 |
+
elif close < sma20 and close < sma50:
|
| 49 |
+
score -= 2
|
| 50 |
+
reasons.append(f"Price is below SMA20 and SMA50 — strong downtrend, bearish signal")
|
| 51 |
+
else:
|
| 52 |
+
reasons.append(f"Price is between SMA20 and SMA50 — mixed trend signals")
|
| 53 |
+
|
| 54 |
+
if close is not None and upper_band is not None and lower_band is not None and middle_band is not None:
|
| 55 |
+
band_range = upper_band - lower_band
|
| 56 |
+
if band_range > 0:
|
| 57 |
+
position = (close - lower_band) / band_range
|
| 58 |
+
if position < 0.2:
|
| 59 |
+
score += 1
|
| 60 |
+
reasons.append(f"Price is near lower Bollinger Band — possible reversal upward")
|
| 61 |
+
elif position > 0.8:
|
| 62 |
+
score -= 1
|
| 63 |
+
reasons.append(f"Price is near upper Bollinger Band — possible reversal downward")
|
| 64 |
+
else:
|
| 65 |
+
reasons.append(f"Price is within Bollinger Bands — normal volatility range")
|
| 66 |
+
|
| 67 |
+
if upper_band is not None and lower_band is not None and middle_band is not None:
|
| 68 |
+
band_width = (upper_band - lower_band) / middle_band * 100
|
| 69 |
+
if band_width > 10:
|
| 70 |
+
risk = "HIGH"
|
| 71 |
+
elif band_width > 5:
|
| 72 |
+
risk = "MEDIUM"
|
| 73 |
+
else:
|
| 74 |
+
risk = "LOW"
|
| 75 |
+
else:
|
| 76 |
+
risk = "MEDIUM"
|
| 77 |
+
|
| 78 |
+
if score >= 4:
|
| 79 |
+
decision = "BUY"
|
| 80 |
+
confidence = min(50 + (score * 8), 95)
|
| 81 |
+
elif score <= -4:
|
| 82 |
+
decision = "SELL"
|
| 83 |
+
confidence = min(50 + (abs(score) * 8), 95)
|
| 84 |
+
else:
|
| 85 |
+
decision = "HOLD"
|
| 86 |
+
confidence = 50 + (abs(score) * 5)
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
"ticker": ticker,
|
| 90 |
+
"decision": decision,
|
| 91 |
+
"confidence": round(confidence, 1),
|
| 92 |
+
"score": score,
|
| 93 |
+
"risk": risk,
|
| 94 |
+
"reasons": reasons,
|
| 95 |
+
"latest": latest
|
| 96 |
+
}
|
app/services/indicators.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import yfinance as yf
|
| 4 |
+
import math
|
| 5 |
+
|
| 6 |
+
def clean_nan(value):
|
| 7 |
+
"""Replace NaN/Inf with None so JSON doesn't crash"""
|
| 8 |
+
if value is None:
|
| 9 |
+
return None
|
| 10 |
+
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
|
| 11 |
+
return None
|
| 12 |
+
return value
|
| 13 |
+
|
| 14 |
+
def clean_list(lst):
|
| 15 |
+
"""Clean an entire list of values"""
|
| 16 |
+
return [clean_nan(x) for x in lst]
|
| 17 |
+
|
| 18 |
+
def calculate_rsi(closes: pd.Series, period: int = 14) -> pd.Series:
|
| 19 |
+
"""
|
| 20 |
+
RSI — Relative Strength Index
|
| 21 |
+
- Above 70 = Overbought (possible SELL signal)
|
| 22 |
+
- Below 30 = Oversold (possible BUY signal)
|
| 23 |
+
- Between 30-70 = Neutral
|
| 24 |
+
"""
|
| 25 |
+
delta = closes.diff()
|
| 26 |
+
gain = delta.where(delta > 0, 0)
|
| 27 |
+
loss = -delta.where(delta < 0, 0)
|
| 28 |
+
avg_gain = gain.ewm(com=period - 1, min_periods=period).mean()
|
| 29 |
+
avg_loss = loss.ewm(com=period - 1, min_periods=period).mean()
|
| 30 |
+
rs = avg_gain / avg_loss
|
| 31 |
+
rsi = 100 - (100 / (1 + rs))
|
| 32 |
+
return rsi.round(2)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def calculate_macd(closes: pd.Series):
|
| 36 |
+
"""
|
| 37 |
+
MACD — Moving Average Convergence Divergence
|
| 38 |
+
- MACD crossing above signal = BUY signal
|
| 39 |
+
- MACD crossing below signal = SELL signal
|
| 40 |
+
"""
|
| 41 |
+
ema12 = closes.ewm(span=12, adjust=False).mean()
|
| 42 |
+
ema26 = closes.ewm(span=26, adjust=False).mean()
|
| 43 |
+
macd_line = ema12 - ema26
|
| 44 |
+
signal_line = macd_line.ewm(span=9, adjust=False).mean()
|
| 45 |
+
histogram = macd_line - signal_line
|
| 46 |
+
return (
|
| 47 |
+
macd_line.round(2),
|
| 48 |
+
signal_line.round(2),
|
| 49 |
+
histogram.round(2)
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def calculate_bollinger_bands(closes: pd.Series, period: int = 20):
|
| 54 |
+
"""
|
| 55 |
+
Bollinger Bands
|
| 56 |
+
- Price above upper band = Overbought
|
| 57 |
+
- Price below lower band = Oversold
|
| 58 |
+
"""
|
| 59 |
+
sma = closes.rolling(window=period).mean()
|
| 60 |
+
std = closes.rolling(window=period).std()
|
| 61 |
+
upper_band = sma + (2 * std)
|
| 62 |
+
lower_band = sma - (2 * std)
|
| 63 |
+
return (
|
| 64 |
+
upper_band.round(2),
|
| 65 |
+
sma.round(2),
|
| 66 |
+
lower_band.round(2)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def calculate_sma(closes: pd.Series, period: int) -> pd.Series:
|
| 71 |
+
"""
|
| 72 |
+
Simple Moving Average
|
| 73 |
+
- SMA20 = short term trend
|
| 74 |
+
- SMA50 = long term trend
|
| 75 |
+
"""
|
| 76 |
+
return closes.rolling(window=period).mean().round(2)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_all_indicators(ticker: str, period: str = "1y"):
|
| 80 |
+
"""
|
| 81 |
+
Master function — fetches stock data and calculates
|
| 82 |
+
ALL indicators in one go.
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
stock = yf.Ticker(ticker)
|
| 86 |
+
df = stock.history(period=period)
|
| 87 |
+
|
| 88 |
+
if df.empty:
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
closes = df["Close"]
|
| 92 |
+
|
| 93 |
+
# Calculate all indicators
|
| 94 |
+
rsi = calculate_rsi(closes)
|
| 95 |
+
macd_line, signal_line, histogram = calculate_macd(closes)
|
| 96 |
+
upper_band, middle_band, lower_band = calculate_bollinger_bands(closes)
|
| 97 |
+
sma20 = calculate_sma(closes, 20)
|
| 98 |
+
sma50 = calculate_sma(closes, 50)
|
| 99 |
+
|
| 100 |
+
# Reset index so Date becomes a column
|
| 101 |
+
df = df.reset_index()
|
| 102 |
+
df["Date"] = df["Date"].dt.strftime("%Y-%m-%d")
|
| 103 |
+
|
| 104 |
+
dates = df["Date"].tolist()
|
| 105 |
+
close_prices = clean_list(closes.round(2).tolist())
|
| 106 |
+
|
| 107 |
+
# Get latest non-NaN values safely
|
| 108 |
+
def latest(series):
|
| 109 |
+
val = series.dropna().iloc[-1] if not series.dropna().empty else None
|
| 110 |
+
return clean_nan(float(val)) if val is not None else None
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
"ticker": ticker,
|
| 114 |
+
"dates": dates,
|
| 115 |
+
"closes": close_prices,
|
| 116 |
+
"rsi": clean_list(rsi.tolist()),
|
| 117 |
+
"macd": {
|
| 118 |
+
"macd_line": clean_list(macd_line.tolist()),
|
| 119 |
+
"signal_line": clean_list(signal_line.tolist()),
|
| 120 |
+
"histogram": clean_list(histogram.tolist())
|
| 121 |
+
},
|
| 122 |
+
"bollinger_bands": {
|
| 123 |
+
"upper": clean_list(upper_band.tolist()),
|
| 124 |
+
"middle": clean_list(middle_band.tolist()),
|
| 125 |
+
"lower": clean_list(lower_band.tolist())
|
| 126 |
+
},
|
| 127 |
+
"sma": {
|
| 128 |
+
"sma20": clean_list(sma20.tolist()),
|
| 129 |
+
"sma50": clean_list(sma50.tolist())
|
| 130 |
+
},
|
| 131 |
+
# Latest values for decision engine
|
| 132 |
+
"latest": {
|
| 133 |
+
"close": latest(closes),
|
| 134 |
+
"rsi": latest(rsi),
|
| 135 |
+
"macd_line": latest(macd_line),
|
| 136 |
+
"signal_line": latest(signal_line),
|
| 137 |
+
"histogram": latest(histogram),
|
| 138 |
+
"upper_band": latest(upper_band),
|
| 139 |
+
"middle_band": latest(middle_band),
|
| 140 |
+
"lower_band": latest(lower_band),
|
| 141 |
+
"sma20": latest(sma20),
|
| 142 |
+
"sma50": latest(sma50),
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"Error calculating indicators for {ticker}: {e}")
|
| 148 |
+
return None
|
app/services/sentiment.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sentiment_service.py
|
| 3 |
+
~~~~~~~~~~~~~~~~~~~~
|
| 4 |
+
Robust news sentiment for any ticker using a tiered approach:
|
| 5 |
+
|
| 6 |
+
Tier 1 — NewsAPI (fast, broad coverage)
|
| 7 |
+
Tier 2 — Yahoo Finance RSS (free, no key needed, good for US stocks)
|
| 8 |
+
Tier 3 — Finnhub (great for less-covered stocks, needs free API key)
|
| 9 |
+
|
| 10 |
+
Each headline is scored with FinBERT. Returns a full response matching
|
| 11 |
+
the SentimentPanel frontend component.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import logging
|
| 18 |
+
import urllib.request
|
| 19 |
+
import urllib.parse
|
| 20 |
+
import json
|
| 21 |
+
import xml.etree.ElementTree as ET
|
| 22 |
+
from functools import lru_cache
|
| 23 |
+
from typing import List
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ── Lazy-load FinBERT ───────────────────────────────────────────────────────────
|
| 31 |
+
@lru_cache(maxsize=1)
|
| 32 |
+
def _get_finbert():
|
| 33 |
+
from transformers import pipeline
|
| 34 |
+
return pipeline(
|
| 35 |
+
"text-classification",
|
| 36 |
+
model="ProsusAI/finbert",
|
| 37 |
+
tokenizer="ProsusAI/finbert",
|
| 38 |
+
device=-1,
|
| 39 |
+
top_k=None,
|
| 40 |
+
truncation=True,
|
| 41 |
+
max_length=512,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
LABEL_MAP = {"positive": 1.0, "negative": -1.0, "neutral": 0.0}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── Score a single headline → (weighted_score, label, confidence) ───────────────
|
| 48 |
+
def _score_one(text: str):
|
| 49 |
+
pipe = _get_finbert()
|
| 50 |
+
result = pipe(text[:512])[0] # list of {label, score}
|
| 51 |
+
weighted = sum(LABEL_MAP[r["label"]] * r["score"] for r in result)
|
| 52 |
+
top = max(result, key=lambda r: r["score"])
|
| 53 |
+
return weighted, top["label"], round(top["score"], 4)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ── Fetchers ────────────────────────────────────────────────────────────────────
|
| 57 |
+
def _fetch_newsapi(ticker: str, company_name: str = "") -> List[dict]:
|
| 58 |
+
api_key = os.getenv("NEWSAPI_KEY", "")
|
| 59 |
+
if not api_key:
|
| 60 |
+
return []
|
| 61 |
+
query = urllib.parse.quote(f"{ticker} OR {company_name}" if company_name else ticker)
|
| 62 |
+
url = (
|
| 63 |
+
f"https://newsapi.org/v2/everything"
|
| 64 |
+
f"?q={query}&language=en&sortBy=publishedAt&pageSize=10"
|
| 65 |
+
f"&apiKey={api_key}"
|
| 66 |
+
)
|
| 67 |
+
try:
|
| 68 |
+
with urllib.request.urlopen(url, timeout=5) as resp:
|
| 69 |
+
data = json.loads(resp.read())
|
| 70 |
+
return [
|
| 71 |
+
{
|
| 72 |
+
"title": a.get("title", ""),
|
| 73 |
+
"url": a.get("url", ""),
|
| 74 |
+
"source": a.get("source", {}).get("name", "NewsAPI"),
|
| 75 |
+
"published_at": a.get("publishedAt", ""),
|
| 76 |
+
}
|
| 77 |
+
for a in data.get("articles", []) if a.get("title")
|
| 78 |
+
]
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.warning("NewsAPI failed for %s: %s", ticker, e)
|
| 81 |
+
return []
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _fetch_yahoo_rss(ticker: str) -> List[dict]:
|
| 85 |
+
url = f"https://feeds.finance.yahoo.com/rss/2.0/headline?s={ticker}®ion=US&lang=en-US"
|
| 86 |
+
try:
|
| 87 |
+
req = urllib.request.Request(url, headers={"User-Agent": "AlphaSignal/1.0"})
|
| 88 |
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
| 89 |
+
root = ET.fromstring(resp.read())
|
| 90 |
+
results = []
|
| 91 |
+
for item in root.findall(".//item")[:15]:
|
| 92 |
+
title = item.findtext("title") or ""
|
| 93 |
+
if title:
|
| 94 |
+
results.append({
|
| 95 |
+
"title": title,
|
| 96 |
+
"url": item.findtext("link") or "",
|
| 97 |
+
"source": "Yahoo Finance",
|
| 98 |
+
"published_at": item.findtext("pubDate") or "",
|
| 99 |
+
})
|
| 100 |
+
return results
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.warning("Yahoo RSS failed for %s: %s", ticker, e)
|
| 103 |
+
return []
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _fetch_finnhub(ticker: str) -> List[dict]:
|
| 107 |
+
api_key = os.getenv("FINNHUB_KEY", "")
|
| 108 |
+
if not api_key:
|
| 109 |
+
return []
|
| 110 |
+
from datetime import date, timedelta
|
| 111 |
+
today = date.today().strftime("%Y-%m-%d")
|
| 112 |
+
week = (date.today() - timedelta(days=7)).strftime("%Y-%m-%d")
|
| 113 |
+
url = (
|
| 114 |
+
f"https://finnhub.io/api/v1/company-news"
|
| 115 |
+
f"?symbol={ticker}&from={week}&to={today}&token={api_key}"
|
| 116 |
+
)
|
| 117 |
+
try:
|
| 118 |
+
with urllib.request.urlopen(url, timeout=5) as resp:
|
| 119 |
+
return [
|
| 120 |
+
{
|
| 121 |
+
"title": a.get("headline", ""),
|
| 122 |
+
"url": a.get("url", ""),
|
| 123 |
+
"source": a.get("source", "Finnhub"),
|
| 124 |
+
"published_at": str(a.get("datetime", "")),
|
| 125 |
+
}
|
| 126 |
+
for a in json.loads(resp.read())[:15] if a.get("headline")
|
| 127 |
+
]
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.warning("Finnhub failed for %s: %s", ticker, e)
|
| 130 |
+
return []
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _dedupe(articles: List[dict]) -> List[dict]:
|
| 134 |
+
seen, out = set(), []
|
| 135 |
+
for a in articles:
|
| 136 |
+
key = a["title"].lower().strip()
|
| 137 |
+
if key not in seen:
|
| 138 |
+
seen.add(key)
|
| 139 |
+
out.append(a)
|
| 140 |
+
return out
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ── Neutral response helper ─────────────────────────────────────────────────────
|
| 144 |
+
def _neutral_response(ticker: str, message: str = "No relevant news found.") -> dict:
|
| 145 |
+
return {
|
| 146 |
+
"ticker": ticker,
|
| 147 |
+
"headline_count": 0,
|
| 148 |
+
"overall_sentiment": "neutral",
|
| 149 |
+
"positive_pct": 0,
|
| 150 |
+
"neutral_pct": 100,
|
| 151 |
+
"negative_pct": 0,
|
| 152 |
+
"score": 0.0,
|
| 153 |
+
"source": "none",
|
| 154 |
+
"message": message,
|
| 155 |
+
"headlines": [],
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ── Public API ──────────────────────────────────────────────────────────────────
|
| 160 |
+
def get_sentiment_score(ticker: str, company_name: str = "") -> dict:
|
| 161 |
+
"""
|
| 162 |
+
Returns a dict matching SentimentPanel.jsx:
|
| 163 |
+
{
|
| 164 |
+
ticker, headline_count, overall_sentiment,
|
| 165 |
+
positive_pct, neutral_pct, negative_pct,
|
| 166 |
+
score, source,
|
| 167 |
+
headlines: [{ title, url, source, published_at, sentiment, score }]
|
| 168 |
+
}
|
| 169 |
+
"""
|
| 170 |
+
articles: List[dict] = []
|
| 171 |
+
articles.extend(_fetch_newsapi(ticker, company_name))
|
| 172 |
+
articles.extend(_fetch_yahoo_rss(ticker))
|
| 173 |
+
if len(articles) < 5:
|
| 174 |
+
articles.extend(_fetch_finnhub(ticker))
|
| 175 |
+
|
| 176 |
+
articles = _dedupe(articles)
|
| 177 |
+
|
| 178 |
+
if len(articles) < 3:
|
| 179 |
+
return _neutral_response(ticker)
|
| 180 |
+
|
| 181 |
+
# Score each headline
|
| 182 |
+
scored = []
|
| 183 |
+
for a in articles:
|
| 184 |
+
try:
|
| 185 |
+
weighted, label, confidence = _score_one(a["title"])
|
| 186 |
+
scored.append({
|
| 187 |
+
"title": a["title"],
|
| 188 |
+
"url": a["url"],
|
| 189 |
+
"source": a["source"],
|
| 190 |
+
"published_at": a["published_at"],
|
| 191 |
+
"sentiment": label, # "positive" | "negative" | "neutral"
|
| 192 |
+
"score": confidence, # shown as "XX% Conf." in the UI
|
| 193 |
+
})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.warning("Scoring failed: %s", e)
|
| 196 |
+
|
| 197 |
+
if not scored:
|
| 198 |
+
return _neutral_response(ticker, "Sentiment scoring failed.")
|
| 199 |
+
|
| 200 |
+
# Aggregate
|
| 201 |
+
n = len(scored)
|
| 202 |
+
pos_count = sum(1 for s in scored if s["sentiment"] == "positive")
|
| 203 |
+
neu_count = sum(1 for s in scored if s["sentiment"] == "neutral")
|
| 204 |
+
neg_count = sum(1 for s in scored if s["sentiment"] == "negative")
|
| 205 |
+
positive_pct = round(pos_count / n * 100)
|
| 206 |
+
neutral_pct = round(neu_count / n * 100)
|
| 207 |
+
negative_pct = round(neg_count / n * 100)
|
| 208 |
+
|
| 209 |
+
overall_score = float(np.mean([LABEL_MAP[s["sentiment"]] * s["score"] for s in scored]))
|
| 210 |
+
|
| 211 |
+
if overall_score >= 0.15:
|
| 212 |
+
overall_sentiment = "positive"
|
| 213 |
+
elif overall_score <= -0.15:
|
| 214 |
+
overall_sentiment = "negative"
|
| 215 |
+
else:
|
| 216 |
+
overall_sentiment = "neutral"
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
"ticker": ticker,
|
| 220 |
+
"headline_count": n,
|
| 221 |
+
"overall_sentiment": overall_sentiment,
|
| 222 |
+
"positive_pct": positive_pct,
|
| 223 |
+
"neutral_pct": neutral_pct,
|
| 224 |
+
"negative_pct": negative_pct,
|
| 225 |
+
"score": round(overall_score, 4),
|
| 226 |
+
"source": "+".join(sorted({a["source"] for a in articles})),
|
| 227 |
+
"headlines": scored,
|
| 228 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
absl-py==2.4.0
|
| 2 |
+
annotated-doc==0.0.4
|
| 3 |
+
annotated-types==0.7.0
|
| 4 |
+
anyio==4.12.1
|
| 5 |
+
astunparse==1.6.3
|
| 6 |
+
beautifulsoup4==4.14.3
|
| 7 |
+
certifi==2026.2.25
|
| 8 |
+
cffi==2.0.0
|
| 9 |
+
charset-normalizer==3.4.6
|
| 10 |
+
click==8.3.1
|
| 11 |
+
curl_cffi==0.13.0
|
| 12 |
+
fastapi==0.135.1
|
| 13 |
+
filelock==3.25.2
|
| 14 |
+
flatbuffers==25.12.19
|
| 15 |
+
frozendict==2.4.7
|
| 16 |
+
fsspec==2026.2.0
|
| 17 |
+
gast==0.7.0
|
| 18 |
+
google-pasta==0.2.0
|
| 19 |
+
grpcio==1.78.0
|
| 20 |
+
h11==0.16.0
|
| 21 |
+
h5py==3.14.0
|
| 22 |
+
hf-xet==1.4.2
|
| 23 |
+
httpcore==1.0.9
|
| 24 |
+
httpx==0.28.1
|
| 25 |
+
huggingface_hub==1.7.2
|
| 26 |
+
idna==3.11
|
| 27 |
+
Jinja2==3.1.6
|
| 28 |
+
joblib==1.5.3
|
| 29 |
+
keras==3.13.2
|
| 30 |
+
libclang==18.1.1
|
| 31 |
+
markdown-it-py==4.0.0
|
| 32 |
+
MarkupSafe==3.0.3
|
| 33 |
+
mdurl==0.1.2
|
| 34 |
+
ml_dtypes==0.5.4
|
| 35 |
+
mpmath==1.3.0
|
| 36 |
+
multitasking==0.0.12
|
| 37 |
+
namex==0.1.0
|
| 38 |
+
networkx==3.6.1
|
| 39 |
+
newsapi-python==0.2.7
|
| 40 |
+
numpy==2.4.3
|
| 41 |
+
opt_einsum==3.4.0
|
| 42 |
+
optree==0.19.0
|
| 43 |
+
packaging==26.0
|
| 44 |
+
pandas==3.0.1
|
| 45 |
+
peewee==4.0.2
|
| 46 |
+
platformdirs==4.9.4
|
| 47 |
+
protobuf==7.34.1
|
| 48 |
+
pycparser==3.0
|
| 49 |
+
pydantic==2.12.5
|
| 50 |
+
pydantic_core==2.41.5
|
| 51 |
+
Pygments==2.19.2
|
| 52 |
+
python-dateutil==2.9.0.post0
|
| 53 |
+
python-dotenv==1.2.2
|
| 54 |
+
pytz==2026.1.post1
|
| 55 |
+
PyYAML==6.0.3
|
| 56 |
+
regex==2026.2.28
|
| 57 |
+
requests==2.32.5
|
| 58 |
+
rich==14.3.3
|
| 59 |
+
safetensors==0.7.0
|
| 60 |
+
scikit-learn==1.8.0
|
| 61 |
+
scipy==1.17.1
|
| 62 |
+
setuptools==81.0.0
|
| 63 |
+
shellingham==1.5.4
|
| 64 |
+
six==1.17.0
|
| 65 |
+
soupsieve==2.8.3
|
| 66 |
+
starlette==1.0.0
|
| 67 |
+
sympy==1.14.0
|
| 68 |
+
tensorflow==2.21.0
|
| 69 |
+
termcolor==3.3.0
|
| 70 |
+
threadpoolctl==3.6.0
|
| 71 |
+
tokenizers==0.22.2
|
| 72 |
+
torch==2.11.0
|
| 73 |
+
tqdm==4.67.3
|
| 74 |
+
transformers==5.3.0
|
| 75 |
+
typer==0.24.1
|
| 76 |
+
typing-inspection==0.4.2
|
| 77 |
+
typing_extensions==4.15.0
|
| 78 |
+
urllib3==2.6.3
|
| 79 |
+
uvicorn==0.42.0
|
| 80 |
+
websockets==16.0
|
| 81 |
+
wheel==0.46.3
|
| 82 |
+
wrapt==2.1.2
|
| 83 |
+
yfinance==1.2.0
|