Samarth1542005 commited on
Commit
60ccd0b
·
1 Parent(s): 56f2c9e

Initial backend deployment

Browse files
.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}&region=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