Phase 1: initial codes
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
from core.market_data import get_quote
|
||||
from utils.notifications import send_toast
|
||||
|
||||
|
||||
class AlertWorker(QThread):
|
||||
alert_triggered = pyqtSignal(str, str, str) # symbol, type, message
|
||||
check_complete = pyqtSignal()
|
||||
|
||||
def __init__(self, interval_seconds: int = 300):
|
||||
super().__init__()
|
||||
self._interval = interval_seconds
|
||||
self._running = False
|
||||
|
||||
def run(self):
|
||||
self._running = True
|
||||
while self._running:
|
||||
self._check_alerts()
|
||||
self.check_complete.emit()
|
||||
for _ in range(self._interval * 10):
|
||||
if not self._running:
|
||||
return
|
||||
self.msleep(100)
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
def _check_alerts(self):
|
||||
from db.database import get_session
|
||||
from db.models import Alert
|
||||
from datetime import datetime
|
||||
|
||||
with get_session() as session:
|
||||
active = session.query(Alert).filter_by(is_active=True).all()
|
||||
for alert in active:
|
||||
try:
|
||||
quote = get_quote(alert.symbol, force=True)
|
||||
price = quote.get("price", 0.0)
|
||||
volume = quote.get("volume", 0)
|
||||
triggered = False
|
||||
message = ""
|
||||
|
||||
if alert.alert_type == "price_above" and price >= alert.target_value:
|
||||
triggered = True
|
||||
message = f"{alert.symbol} hit ${price:.2f} (above ${alert.target_value:.2f})"
|
||||
elif alert.alert_type == "price_below" and price <= alert.target_value:
|
||||
triggered = True
|
||||
message = f"{alert.symbol} hit ${price:.2f} (below ${alert.target_value:.2f})"
|
||||
elif alert.alert_type == "volume_spike":
|
||||
avg_vol = quote.get("avg_volume") or (volume / 1.5)
|
||||
if avg_vol and volume >= avg_vol * alert.target_value:
|
||||
triggered = True
|
||||
message = f"{alert.symbol} volume spike: {volume:,} ({alert.target_value:.1f}x avg)"
|
||||
|
||||
if triggered:
|
||||
alert.is_active = False
|
||||
alert.triggered_at = datetime.utcnow()
|
||||
session.commit()
|
||||
send_toast("StockMind Alert", message)
|
||||
self.alert_triggered.emit(alert.symbol, alert.alert_type, message)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
_quote_cache: dict[str, tuple[dict, float]] = {}
|
||||
_CACHE_TTL = 60 # seconds
|
||||
|
||||
|
||||
def get_quote(symbol: str, force: bool = False) -> dict:
|
||||
now = time.time()
|
||||
if not force and symbol in _quote_cache:
|
||||
data, ts = _quote_cache[symbol]
|
||||
if now - ts < _CACHE_TTL:
|
||||
return data
|
||||
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
info = ticker.fast_info
|
||||
hist = ticker.history(period="2d", interval="1d")
|
||||
|
||||
price = float(info.last_price or 0)
|
||||
prev_close = float(info.previous_close or price)
|
||||
change = price - prev_close
|
||||
change_pct = (change / prev_close * 100) if prev_close else 0.0
|
||||
|
||||
data = {
|
||||
"symbol": symbol,
|
||||
"price": price,
|
||||
"change": change,
|
||||
"change_pct": change_pct,
|
||||
"volume": int(info.three_month_average_volume or 0),
|
||||
"market_cap": getattr(info, "market_cap", None),
|
||||
"prev_close": prev_close,
|
||||
"day_high": float(getattr(info, "day_high", price) or price),
|
||||
"day_low": float(getattr(info, "day_low", price) or price),
|
||||
"fifty_two_week_high": float(getattr(info, "fifty_two_week_high", 0) or 0),
|
||||
"fifty_two_week_low": float(getattr(info, "fifty_two_week_low", 0) or 0),
|
||||
"error": None,
|
||||
}
|
||||
except Exception as e:
|
||||
data = {
|
||||
"symbol": symbol, "price": 0.0, "change": 0.0, "change_pct": 0.0,
|
||||
"volume": 0, "market_cap": None, "prev_close": 0.0,
|
||||
"day_high": 0.0, "day_low": 0.0,
|
||||
"fifty_two_week_high": 0.0, "fifty_two_week_low": 0.0,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
_quote_cache[symbol] = (data, now)
|
||||
return data
|
||||
|
||||
|
||||
def get_history(symbol: str, period: str = "6mo", interval: str = "1d") -> pd.DataFrame:
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
df = ticker.history(period=period, interval=interval)
|
||||
df.index = pd.to_datetime(df.index)
|
||||
return df
|
||||
except Exception:
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def get_fundamentals(symbol: str) -> dict:
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
info = ticker.info
|
||||
return {
|
||||
"name": info.get("longName", symbol),
|
||||
"sector": info.get("sector", ""),
|
||||
"industry": info.get("industry", ""),
|
||||
"market_cap": info.get("marketCap"),
|
||||
"pe_ratio": info.get("trailingPE"),
|
||||
"forward_pe": info.get("forwardPE"),
|
||||
"eps": info.get("trailingEps"),
|
||||
"revenue": info.get("totalRevenue"),
|
||||
"profit_margin": info.get("profitMargins"),
|
||||
"dividend_yield": info.get("dividendYield"),
|
||||
"beta": info.get("beta"),
|
||||
"week_52_high": info.get("fiftyTwoWeekHigh"),
|
||||
"week_52_low": info.get("fiftyTwoWeekLow"),
|
||||
"avg_volume": info.get("averageVolume"),
|
||||
"description": info.get("longBusinessSummary", ""),
|
||||
}
|
||||
except Exception:
|
||||
return {"name": symbol, "sector": "", "industry": "", "error": True}
|
||||
|
||||
|
||||
def search_symbols(query: str) -> list[dict]:
|
||||
if not query or len(query) < 1:
|
||||
return []
|
||||
try:
|
||||
results = yf.Search(query, max_results=10)
|
||||
quotes = results.quotes if hasattr(results, "quotes") else []
|
||||
return [
|
||||
{"symbol": q.get("symbol", ""), "name": q.get("shortname", q.get("longname", ""))}
|
||||
for q in quotes
|
||||
if q.get("symbol")
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
PERIOD_MAP = {
|
||||
"1D": ("1d", "5m"),
|
||||
"1W": ("5d", "15m"),
|
||||
"1M": ("1mo", "1h"),
|
||||
"3M": ("3mo", "1d"),
|
||||
"6M": ("6mo", "1d"),
|
||||
"1Y": ("1y", "1d"),
|
||||
"5Y": ("5y", "1wk"),
|
||||
}
|
||||
|
||||
|
||||
def get_chart_data(symbol: str, period_label: str = "6M") -> pd.DataFrame:
|
||||
period, interval = PERIOD_MAP.get(period_label, ("6mo", "1d"))
|
||||
return get_history(symbol, period=period, interval=interval)
|
||||
|
||||
|
||||
def get_batch_quotes(symbols: list[str]) -> dict[str, dict]:
|
||||
results = {}
|
||||
for sym in symbols:
|
||||
results[sym] = get_quote(sym)
|
||||
return results
|
||||
|
||||
|
||||
SCREENER_UNIVERSE = [
|
||||
"AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "BRK-B", "UNH", "JPM",
|
||||
"V", "XOM", "JNJ", "PG", "MA", "HD", "CVX", "MRK", "ABBV", "PEP",
|
||||
"KO", "AVGO", "COST", "LLY", "MCD", "TMO", "ACN", "BAC", "CSCO", "WMT",
|
||||
"ABT", "CRM", "DIS", "NFLX", "AMD", "INTC", "QCOM", "TXN", "PYPL", "AMGN",
|
||||
"SPY", "QQQ", "DIA", "GLD", "SLV",
|
||||
"BTC-USD", "ETH-USD", "SOL-USD", "BNB-USD", "ADA-USD",
|
||||
]
|
||||
|
||||
|
||||
def get_screener_data(symbols: list[str] | None = None) -> list[dict]:
|
||||
if symbols is None:
|
||||
symbols = SCREENER_UNIVERSE
|
||||
results = []
|
||||
for sym in symbols:
|
||||
try:
|
||||
ticker = yf.Ticker(sym)
|
||||
info = ticker.info
|
||||
fast = ticker.fast_info
|
||||
hist = ticker.history(period="1y", interval="1d")
|
||||
rsi_val = None
|
||||
if len(hist) >= 14:
|
||||
delta = hist["Close"].diff()
|
||||
gain = delta.clip(lower=0).rolling(14).mean()
|
||||
loss = (-delta.clip(upper=0)).rolling(14).mean()
|
||||
rs = gain / loss
|
||||
rsi_series = 100 - (100 / (1 + rs))
|
||||
rsi_val = round(float(rsi_series.iloc[-1]), 1) if not rsi_series.empty else None
|
||||
|
||||
price = float(fast.last_price or 0)
|
||||
week_52_high = float(getattr(fast, "year_high", 0) or 0)
|
||||
week_52_low = float(getattr(fast, "year_low", 0) or 0)
|
||||
|
||||
results.append({
|
||||
"symbol": sym,
|
||||
"name": info.get("shortName", sym),
|
||||
"price": price,
|
||||
"sector": info.get("sector", ""),
|
||||
"market_cap": info.get("marketCap"),
|
||||
"pe_ratio": info.get("trailingPE"),
|
||||
"rsi": rsi_val,
|
||||
"week_52_high": week_52_high,
|
||||
"week_52_low": week_52_low,
|
||||
"pct_from_52h": round((price - week_52_high) / week_52_high * 100, 1) if week_52_high else None,
|
||||
"pct_from_52l": round((price - week_52_low) / week_52_low * 100, 1) if week_52_low else None,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
from core.market_data import get_quote, get_history
|
||||
|
||||
|
||||
def calculate_pnl(positions: list[dict], quotes: dict[str, dict]) -> list[dict]:
|
||||
result = []
|
||||
for pos in positions:
|
||||
sym = pos["symbol"]
|
||||
q = quotes.get(sym, {})
|
||||
price = q.get("price", 0.0)
|
||||
shares = pos.get("shares", 0.0)
|
||||
avg_cost = pos.get("avg_cost", 0.0)
|
||||
market_value = price * shares
|
||||
cost_basis = avg_cost * shares
|
||||
pnl = market_value - cost_basis
|
||||
pnl_pct = (pnl / cost_basis * 100) if cost_basis else 0.0
|
||||
result.append({
|
||||
**pos,
|
||||
"price": price,
|
||||
"market_value": market_value,
|
||||
"cost_basis": cost_basis,
|
||||
"pnl": pnl,
|
||||
"pnl_pct": pnl_pct,
|
||||
"change_pct": q.get("change_pct", 0.0),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_benchmark_performance(period: str = "1y") -> pd.DataFrame:
|
||||
return get_history("SPY", period=period)
|
||||
|
||||
|
||||
def calculate_sector_allocation(positions: list[dict]) -> dict[str, float]:
|
||||
sector_values: dict[str, float] = {}
|
||||
for pos in positions:
|
||||
sector = pos.get("sector", "Unknown") or "Unknown"
|
||||
value = pos.get("market_value", 0.0)
|
||||
sector_values[sector] = sector_values.get(sector, 0.0) + value
|
||||
total = sum(sector_values.values())
|
||||
if total == 0:
|
||||
return {}
|
||||
return {s: (v / total * 100) for s, v in sorted(sector_values.items(), key=lambda x: -x[1])}
|
||||
|
||||
|
||||
def get_portfolio_performance(positions: list[dict], period: str = "1y") -> pd.DataFrame:
|
||||
if not positions:
|
||||
return pd.DataFrame()
|
||||
|
||||
symbols = [p["symbol"] for p in positions]
|
||||
weights = {}
|
||||
total_value = sum(p.get("market_value", 0) for p in positions)
|
||||
|
||||
if total_value == 0:
|
||||
return pd.DataFrame()
|
||||
|
||||
for p in positions:
|
||||
weights[p["symbol"]] = p.get("market_value", 0) / total_value
|
||||
|
||||
frames = []
|
||||
for sym in symbols:
|
||||
hist = get_history(sym, period=period)
|
||||
if not hist.empty:
|
||||
pct = hist["Close"].pct_change().fillna(0)
|
||||
pct.name = sym
|
||||
frames.append(pct * weights.get(sym, 0))
|
||||
|
||||
if not frames:
|
||||
return pd.DataFrame()
|
||||
|
||||
combined = pd.concat(frames, axis=1).fillna(0)
|
||||
portfolio_returns = combined.sum(axis=1)
|
||||
portfolio_cumulative = (1 + portfolio_returns).cumprod() - 1
|
||||
|
||||
spy = get_history("SPY", period=period)
|
||||
if not spy.empty:
|
||||
spy_returns = spy["Close"].pct_change().fillna(0)
|
||||
spy_cumulative = (1 + spy_returns).cumprod() - 1
|
||||
return pd.DataFrame({
|
||||
"portfolio": portfolio_cumulative,
|
||||
"spy": spy_cumulative,
|
||||
}).dropna()
|
||||
|
||||
return pd.DataFrame({"portfolio": portfolio_cumulative}).dropna()
|
||||
Reference in New Issue
Block a user