stocks / modules /analysis /helpers.py
Arrechenash's picture
Initial Commit
d0d8bc9
Raw
History Blame Contribute Delete
48.4 kB
"""Helper functions for stock analysis endpoints.
Extracted from fastapi_app.py to break up the monolithic router.
All functions here are async-friendly (run blocking I/O in thread pools).
"""
import asyncio
import html
import json
import logging
import os
import re
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from pathlib import Path
import duckdb
import httpx
import pandas as pd
from core.shared.cache import cache_get, cache_set
from core.shared.cache import cache_key as _cache_key_fn
from core.shared.deps import (
data_manager,
defeatbeta,
finviz,
smallcaplab,
stockanalysis,
)
from core.shared.file_io import atomic_write_json
from core.splits_provider import adjust_for_splits, splits_provider
# Pool global de threads para providers — evita crear/destruir threads por request
_PROVIDER_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="provider")
logger = logging.getLogger(__name__)
# Re-export cache_key for use in routes
cache_key = _cache_key_fn
# ── NASDAQ non-compliant list cache (6h) ──
NON_COMPLIANT_CACHE_FILE = Path("data/cache/nasdaq_non_compliant.json")
NON_COMPLIANT_TTL = 21600 # 6 hours
NON_COMPLIANT_CACHE: dict[str, dict] | None = None
# Serializes the lazy NASDAQ fetch+write so two concurrent loaders
# don't both hit the network and race on the cache file.
_NON_COMPLIANT_LOCK = threading.Lock()
def load_non_compliant_cache() -> dict[str, dict]:
"""Load/refresh the NASDAQ non-compliant list."""
global NON_COMPLIANT_CACHE
now = _time.time()
if NON_COMPLIANT_CACHE is not None:
return NON_COMPLIANT_CACHE
try:
if NON_COMPLIANT_CACHE_FILE.exists():
mtime = os.path.getmtime(NON_COMPLIANT_CACHE_FILE)
if (now - mtime) < NON_COMPLIANT_TTL:
with open(NON_COMPLIANT_CACHE_FILE) as f:
data = json.load(f)
NON_COMPLIANT_CACHE = data
return data
except Exception:
pass
try:
url = "https://api.nasdaq.com/api/quote/list-type-extended/listing?queryString=deficient"
headers = {
"origin": "https://www.nasdaq.com",
"referer": "https://www.nasdaq.com/",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}
resp = httpx.get(url, headers=headers, timeout=15)
resp.raise_for_status()
payload = resp.json()
rows = payload.get("data", {}).get("noncomplaintCompanyList", {}).get("rows", [])
lookup: dict[str, dict] = {}
for issuer in rows:
issuer_name = issuer.get("IssuerName", "")
for company_entry in issuer.get("companies", []):
info = {
"issuer_name": issuer_name,
"deficiency": company_entry.get("Deficiency", ""),
"market": company_entry.get("Market", ""),
"notification_date": company_entry.get("NotificationDate", ""),
}
for sym in company_entry.get("AffectedIssues", []):
if sym:
lookup[sym.upper()] = info
with _NON_COMPLIANT_LOCK:
# Re-check: a concurrent loader may have already written it.
if NON_COMPLIANT_CACHE is None:
NON_COMPLIANT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(NON_COMPLIANT_CACHE_FILE, lookup)
NON_COMPLIANT_CACHE = lookup
logger.info(f"Loaded NASDAQ non-compliant list: {len(lookup)} securities")
return lookup
except Exception as e:
logger.warning(f"Failed to fetch NASDAQ non-compliant list: {e}")
NON_COMPLIANT_CACHE = {}
return {}
def non_compliant_info(symbol: str) -> dict | None:
"""Return non-compliant info for symbol, or None if compliant/unknown."""
lookup = load_non_compliant_cache()
return lookup.get(symbol.upper())
# ── Executive title filters ──
EXECUTIVE_TITLE_FILTERS = [
"chairman",
"chairwoman",
"board of director",
"board member",
"independent director",
"lead independent",
"managing director",
"ceo",
"chief executive",
"cfo",
"chief financial",
"coo",
"chief operating",
"cto",
"chief technology",
"cio",
"chief information",
"cmo",
"chief marketing",
"chro",
"chief human",
"chief scientific",
"chief medical",
"chief compliance",
"chief accounting",
"chief legal",
"chief admin",
"cso",
"chief strategy",
"chief revenue",
"chief growth",
"president",
"co-president",
"vice president",
"svp",
"senior vice president",
"evp",
"executive vice president",
"vp",
"founder",
"secretary",
"treasurer",
"general counsel",
"managing member",
]
def title_matches(title: str) -> bool:
tl = title.lower().strip()
return any(kw in tl for kw in EXECUTIVE_TITLE_FILTERS)
def extract_board(company_officers: list[dict]) -> list[dict]:
if not company_officers:
return []
seen: set[str] = set()
result: list[dict] = []
for o in company_officers:
raw_name = o.get("name", "")
raw_title = o.get("title", "")
if not raw_name:
continue
title = raw_title.strip() if raw_title else ""
if title and not title_matches(title):
continue
name = raw_name.strip()
if name.lower() in seen:
continue
seen.add(name.lower())
role = title if title_matches(title) else raw_title.strip()
result.append({"role": role or "", "name": name})
return result
# ── SEC dilution risk ──
DILUTION_FORM_TYPES = {
"S-3",
"S-3/A",
"S-1",
"S-1/A",
"424B1",
"424B2",
"424B3",
"424B4",
"424B5",
"D",
"D/A",
"S-8",
"S-8/A",
}
WARRANT_KEYWORDS = ["warrant", "warrants", "Warrant", "Warrants"]
ATM_KEYWORDS = [
"at the market",
"atm offering",
"ATM",
"equity distribution",
"sales agreement",
"controlled equity",
]
def fetch_sec_dilution_risk(symbol: str) -> dict:
try:
import httpx
cik = None
try:
with duckdb.connect(":memory:") as con:
meta_path = data_manager.metadata_db_path
con.execute(f"ATTACH '{meta_path}' AS meta (TYPE DUCKDB)")
res = con.execute("SELECT cik FROM meta.tickers WHERE symbol = ?", [symbol.upper()]).fetchone()
if res and res[0]:
cik = str(res[0]).strip().zfill(10)
except Exception:
pass
if not cik:
try:
headers = {
"User-Agent": "Stock Scanner (email@example.com)",
"Accept": "application/json",
}
resp = httpx.get(
"https://www.sec.gov/files/company_tickers.json",
headers=headers,
timeout=10,
)
resp.raise_for_status()
tickers = resp.json()
for entry in tickers.values():
if entry.get("ticker", "").upper() == symbol.upper():
cik = str(entry["cik_str"]).zfill(10)
break
except Exception:
pass
if not cik:
return {}
headers = {
"User-Agent": "Stock Scanner (email@example.com)",
"Accept": "application/json",
}
url = f"https://data.sec.gov/submissions/CIK{cik}.json"
resp = httpx.get(url, headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
filings_data = []
has_warrants = False
has_atm = False
has_s3 = False
recent = data.get("filings", {}).get("recent", {})
forms = recent.get("form", [])
dates = recent.get("filingDate", [])
descs = recent.get("description", [])
for i, form in enumerate(forms):
if form in DILUTION_FORM_TYPES:
date = dates[i] if i < len(dates) else ""
desc = (descs[i] if i < len(descs) else "") or ""
clean_desc = html.unescape(re.sub(r"<[^>]+>", "", desc)) if desc else ""
filings_data.append(
{
"form": form,
"date": date,
"description": clean_desc[:300] if clean_desc else "",
}
)
if form.startswith("S-3"):
has_s3 = True
desc = (descs[i] if i < len(descs) else "") or ""
if desc:
desc_lower = desc.lower()
for kw in WARRANT_KEYWORDS:
if kw.lower() in desc_lower:
has_warrants = True
break
for kw in ATM_KEYWORDS:
if kw.lower() in desc_lower:
has_atm = True
break
return {
"filings": filings_data[:10],
"has_warrants": has_warrants,
"has_atm": has_atm,
"active_shelf": has_s3,
}
except Exception as e:
logger.debug(f"SEC dilution risk failed for {symbol}: {e}")
return {}
# ── SEC resale-unlock (EFFECT) extraction ──
RESALE_REG_TYPES = {"S-1", "S-1/A", "S-3", "S-3/A", "F-1", "F-3", "F-1/A", "F-3/A"}
EFFECT_FORM = "EFFECT"
EFFECT_WINDOW_DAYS = 21 # unlock within N days = distribution-danger window
LOCKUP_DEFAULT_DAYS = 180 # typical placement/insider lock-up length
LOCKUP_WINDOW_DAYS = 30 # |days to est. expiry| within N = volatility window
def fetch_sec_unlocks(symbol: str) -> dict | None:
"""Return resale-registration unlock info, or None.
Detects resale registration statements (S-1/S-3/F-1/F-3 + /A) and their
EFFECT filing (filingDate == effectiveness/unlock date). Mirrors the CIK +
submissions plumbing already in fetch_sec_dilution_risk.
"""
try:
from datetime import datetime as _dt
from datetime import timedelta
import httpx
cik = None
try:
with duckdb.connect(":memory:") as con:
meta_path = data_manager.metadata_db_path
con.execute(f"ATTACH '{meta_path}' AS meta (TYPE DUCKDB)")
res = con.execute("SELECT cik FROM meta.tickers WHERE symbol = ?", [symbol.upper()]).fetchone()
if res and res[0]:
cik = str(res[0]).strip().zfill(10)
except Exception:
pass
if not cik:
try:
headers = {
"User-Agent": "Stock Scanner (email@example.com)",
"Accept": "application/json",
}
resp = httpx.get(
"https://www.sec.gov/files/company_tickers.json",
headers=headers,
timeout=10,
)
resp.raise_for_status()
tickers = resp.json()
for entry in tickers.values():
if entry.get("ticker", "").upper() == symbol.upper():
cik = str(entry["cik_str"]).zfill(10)
break
except Exception:
pass
if not cik:
return None
headers = {
"User-Agent": "Stock Scanner (email@example.com)",
"Accept": "application/json",
}
url = f"https://data.sec.gov/submissions/CIK{cik}.json"
resp = httpx.get(url, headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
recent = data.get("filings", {}).get("recent", {})
forms = recent.get("form", [])
dates = recent.get("filingDate", [])
descs = recent.get("description", [])
resale_forms = []
effect_dates = []
has_lockup = False
lockup_days = None
for i, form in enumerate(forms):
d = dates[i] if i < len(dates) else ""
if form in RESALE_REG_TYPES:
resale_forms.append({"form": form, "date": d})
if form == EFFECT_FORM and d:
effect_dates.append(d)
# lock-up detection from filing descriptions (prose heuristic)
desc = (descs[i] if i < len(descs) else "") or ""
if desc:
dl = desc.lower()
if "lock" in dl:
has_lockup = True
if lockup_days is None:
m = re.search(r"(\d{1,3})\s*-?\s*day", dl)
if m:
lockup_days = int(m.group(1))
if not resale_forms and not effect_dates:
return None
last_effect = max(effect_dates) if effect_dates else None
days_since = None
if last_effect:
try:
# datetime.strptime returns datetime; .date() -> date; now().date() - date -> days
eff_date = _dt.strptime(last_effect, "%Y-%m-%d").date()
days_since = (_dt.now().date() - eff_date).days
except Exception:
pass
resale_dates = [rf["date"] for rf in resale_forms if rf["date"]]
# pending = a resale registration filed with no effective EFFECT yet
has_pending = bool(resale_forms) and (last_effect is None or any(rd > last_effect for rd in resale_dates))
in_window = days_since is not None and 0 <= days_since <= EFFECT_WINDOW_DAYS
# Lock-up estimate: anchored on the resale-reg EFFECT, length from
# filing prose if detectable else typical 180d. Clearly an ESTIMATE.
lockup_est_end = None
days_to_lockup_est = None
in_lockup_est_window = False
if last_effect:
try:
base = _dt.strptime(last_effect, "%Y-%m-%d").date()
end = base + timedelta(days=lockup_days or LOCKUP_DEFAULT_DAYS)
lockup_est_end = end.isoformat()
days_to_lockup_est = (_dt.now().date() - end).days
in_lockup_est_window = abs(days_to_lockup_est) <= LOCKUP_WINDOW_DAYS
except Exception:
pass
return {
"has_pending_registration": has_pending,
"last_effect_date": last_effect,
"days_since_effect": days_since,
"resale_forms": [rf["form"] for rf in resale_forms],
"in_unlock_window": in_window,
"has_lockup": has_lockup,
"lockup_est_end_date": lockup_est_end,
"days_to_lockup_est": days_to_lockup_est,
"in_lockup_est_window": in_lockup_est_window,
}
except Exception as e:
logger.debug(f"SEC unlock check failed for {symbol}: {e}")
return None
def fetch_press_release(symbol: str) -> list[dict]:
try:
import yfinance as yf
ticker = yf.Ticker(symbol.upper())
news = ticker.get_news(count=1, tab="press releases")
if not news:
return []
item = news[0].get("content", {})
title = item.get("title", "")
pub_date = item.get("pubDate", "")
click_url = item.get("clickThroughUrl", {})
url = click_url.get("url", "") if isinstance(click_url, dict) else ""
if title:
return [{"headline": title, "url": url, "created_at": pub_date}]
return []
except Exception:
return []
def parse_finviz_financial_number(val: str | None) -> float | None:
if not val or val == "-" or val == "":
return None
try:
from core.finviz_provider import safe_finviz_number
return safe_finviz_number(val)
except Exception:
pass
try:
val_str = str(val).upper().strip()
if "B" in val_str:
return float(val_str.replace("B", "")) * 1_000_000_000
if "M" in val_str:
return float(val_str.replace("M", "")) * 1_000_000
if "K" in val_str:
return float(val_str.replace("K", "")) * 1_000
if "T" in val_str:
return float(val_str.replace("T", "")) * 1_000_000_000_000
return float(val_str.replace("%", ""))
except (ValueError, TypeError):
return None
def parse_earnings_date(raw: str) -> str | None:
if not raw or raw == "-":
return None
clean = raw.split()[0:2]
if len(clean) < 2:
return None
try:
dt = datetime.strptime(f"{clean[0]} {clean[1]}", "%b %d")
now = datetime.now()
# Check both current and next year to handle year boundaries correctly.
# e.g., on Dec 30, "Jan 5" should map to next year's Jan 5, not this year's.
candidates = [
dt.replace(year=now.year),
dt.replace(year=now.year + 1),
]
# Pick the closest future date
future = [c for c in candidates if c > now]
if not future:
return None
return min(future).strftime("%Y-%m-%d")
except ValueError:
return None
def compute_sma(
daily_df: pd.DataFrame,
period: int,
symbol: str | None = None,
data_manager=None,
) -> float | None:
"""Compute SMA over the last `period` closes from split-adjusted daily data.
Returns None when daily_df is empty or has fewer than `period` rows.
SQL fallback removed — raw unadjusted data gives wrong results for
reverse-split tickers.
"""
if daily_df is not None and not daily_df.empty:
closes = pd.to_numeric(daily_df.get("close", pd.Series(dtype=float)), errors="coerce").dropna()
if len(closes) >= period:
return float(closes.tail(period).mean())
return None
def get_52w_high(daily_df: pd.DataFrame) -> float | None:
if daily_df.empty:
return None
highs = pd.to_numeric(daily_df.get("high", pd.Series()), errors="coerce").dropna()
if len(highs) < 252:
return float(highs.max()) if not highs.empty else None
return float(highs.tail(252).max())
def get_corporate_actions_dict(symbol: str) -> dict:
try:
df = data_manager.get_corporate_actions(symbol, days=365)
if df.empty:
return {}
result: dict = {}
for _, row in df.iterrows():
ca_type = row.get("ca_sub_type", "")
if not ca_type:
continue
entry = {"ex_date": str(row.get("ex_date", ""))}
ca_ratio = row.get("ca_ratio", "")
ca_amount = row.get("ca_amount", "")
if ca_ratio and pd.notna(ca_ratio) and ca_ratio != "":
entry["ca_ratio"] = str(ca_ratio)
if ca_amount and pd.notna(ca_amount):
entry["ca_amount"] = float(ca_amount)
result.setdefault(ca_type, []).append(entry)
return result
except Exception as e:
logger.warning(f"Corporate actions fetch failed for {symbol}: {e}")
return {}
def compute_resistance_levels(
daily_df: pd.DataFrame,
price_now: float,
symbol: str | None = None,
data_manager=None,
) -> list[dict]:
"""Compute overhead resistance levels from split-adjusted daily data.
Returns empty list when daily_df is empty or has insufficient data.
SQL fallback removed — raw unadjusted data gives wrong results for
reverse-split tickers.
"""
if daily_df is None or daily_df.empty:
return []
df = daily_df.copy()
df["date"] = pd.to_datetime(df["timestamp"]).dt.date
window_start = datetime.now().date() - timedelta(days=63)
window = df[df["date"] >= window_start]
levels = []
if len(window) >= 10:
window["vol_avg"] = window["volume"].rolling(20, min_periods=5).mean()
above = window[window["high"] > price_now]
spiked = above[above["volume"] > 2 * above["vol_avg"]] if not above.empty else above
candidate = spiked.iloc[-1] if not spiked.empty else (above.iloc[-1] if not above.empty else None)
if candidate is not None:
vol_ratio = (
round(float(candidate["volume"]) / float(candidate["vol_avg"]), 1)
if pd.notna(candidate["vol_avg"]) and candidate["vol_avg"] > 0
else None
)
levels.append(
{
"level": "OH",
"label": "Overhead",
"price": float(candidate["high"]),
"volume": int(candidate["volume"]),
"vol_ratio": vol_ratio,
"date": candidate["date"].isoformat(),
"source": "daily_adjusted",
}
)
levels.sort(key=lambda x: x["price"])
return levels
def compute_support_levels(
daily_df: pd.DataFrame,
price_now: float,
symbol: str | None = None,
data_manager=None,
) -> list[dict]:
"""Compute support levels below current price from split-adjusted daily data.
Returns empty list when daily_df is empty or has insufficient data.
SQL fallback removed — raw unadjusted data gives wrong results for
reverse-split tickers.
"""
if daily_df is None or daily_df.empty:
return []
df = daily_df.copy()
df["date"] = pd.to_datetime(df["timestamp"]).dt.date
window_start = datetime.now().date() - timedelta(days=63)
window = df[df["date"] >= window_start]
levels = []
if len(window) >= 10:
window["vol_avg"] = window["volume"].rolling(20, min_periods=5).mean()
below = window[window["low"] < price_now]
spiked = below[below["volume"] > 2 * below["vol_avg"]] if not below.empty else below
candidate = spiked.iloc[-1] if not spiked.empty else (below.iloc[-1] if not below.empty else None)
if candidate is not None:
vol_ratio = (
round(float(candidate["volume"]) / float(candidate["vol_avg"]), 1)
if pd.notna(candidate["vol_avg"]) and candidate["vol_avg"] > 0
else None
)
levels.append(
{
"level": "SL",
"label": "Support",
"price": float(candidate["low"]),
"volume": int(candidate["volume"]),
"vol_ratio": vol_ratio,
"date": candidate["date"].isoformat(),
"source": "daily_adjusted",
}
)
levels.sort(key=lambda x: x["price"])
return levels
def fetch_duckdb_data(symbol: str) -> dict:
"""Fetch all DuckDB data for a symbol in one shot (gap/run history)."""
daily_df = data_manager.get_all_daily_data(symbol)
gap_history: list = []
run_history: list = []
if not daily_df.empty:
try:
daily_df = daily_df.copy()
prev_close = daily_df["close"].shift(1)
daily_df["gap_pct"] = (daily_df["open"] - prev_close) / prev_close.replace(0, float("nan"))
daily_df["run_pct"] = (daily_df["close"] - daily_df["open"]) / daily_df["open"].replace(0, float("nan"))
daily_df["change_pct"] = (daily_df["close"] - prev_close) / prev_close.replace(0, float("nan"))
except Exception as e:
logger.warning(f"Column computation failed for {symbol}: {e}")
try:
mask = (daily_df["gap_pct"] >= 0.20) & (daily_df["volume"] > 1_000_000)
gh = daily_df[mask].copy()
if not gh.empty:
gh = gh.sort_values("timestamp", ascending=False).head(10)
gh["date"] = gh["timestamp"].dt.strftime("%Y-%m-%d")
gh["gap_pct"] = (gh["gap_pct"] * 100).round(1)
gh["vol_M"] = (gh["volume"] / 1_000_000).round(1)
gh["return_pct"] = (gh["change_pct"] * 100).round(1)
gh["close_dir"] = gh["close"].gt(gh["open"]).map({True: "UP", False: "DOWN"})
gap_history = gh[["date", "gap_pct", "vol_M", "return_pct", "close_dir"]].to_dict(orient="records")
except Exception as e:
logger.warning(f"Gap history failed for {symbol}: {e}")
try:
mask = (daily_df["run_pct"] >= 0.50) & (daily_df["volume"] > 1_000_000)
rh = daily_df[mask].copy()
if not rh.empty:
rh = rh.sort_values("timestamp", ascending=False).head(10)
rh["date"] = rh["timestamp"].dt.strftime("%Y-%m-%d")
rh["run_pct"] = (rh["run_pct"] * 100).round(1)
rh["vol_M"] = (rh["volume"] / 1_000_000).round(1)
rh["return_pct"] = (rh["change_pct"] * 100).round(1)
rh["close_dir"] = rh["close"].gt(rh["open"]).map({True: "UP", False: "DOWN"})
run_history = rh[["date", "run_pct", "vol_M", "return_pct", "close_dir"]].to_dict(orient="records")
except Exception as e:
logger.warning(f"Run history failed for {symbol}: {e}")
return {"daily_df": daily_df, "gap_history": gap_history, "run_history": run_history}
def build_stock_result(
symbol: str,
finviz_overview: dict,
corporate_actions: dict,
next_earnings_date: str | None,
daily_df: pd.DataFrame,
gap_history: list,
run_history: list,
finviz_catalyst: dict | None = None,
employees: int | None = None,
company_description: str = "",
float_finviz: int = 0,
company_officers: list | None = None,
nasdaq_non_compliant: dict | None = None,
sec_unlocks: dict | None = None,
financials: dict | None = None,
dilution_risk: dict | None = None,
stockanalysis_data: dict | None = None,
smallcaplab_data: dict | None = None,
press_releases: list | None = None,
) -> dict:
from core.chinese_profile import score_chinese_profile
from core.theme_inference import infer_themes
finviz_catalyst = finviz_catalyst or {"symbol": symbol, "has_catalyst": False}
company_officers = company_officers or finviz_overview.get("company_officers", [])
reverse_splits = corporate_actions.get("reverse_split", []) if isinstance(corporate_actions, dict) else []
last_reverse_split = reverse_splits[0] if reverse_splits else None
forward_splits = corporate_actions.get("forward_split", []) if isinstance(corporate_actions, dict) else []
last_forward_split = forward_splits[0] if forward_splits else None
price_now = finviz_overview.get("price", 0) or 0
resistance_levels = compute_resistance_levels(daily_df, price_now, symbol, data_manager)
for key, label in [("52w_high", "52W High"), ("sma_200", "SMA 200d")]:
val = finviz_overview.get(key)
if val and float(val) > price_now:
resistance_levels.append(
{
"level": key,
"label": label,
"price": float(val),
"source": "daily_adjusted",
}
)
resistance_levels.sort(key=lambda x: x["price"])
oh_level = next((r for r in resistance_levels if r.get("level") == "OH"), None)
overhead_resistance = oh_level or (resistance_levels[0] if resistance_levels else None)
if overhead_resistance:
overhead_resistance = {
"price": overhead_resistance["price"],
"volume": overhead_resistance.get("volume"),
"date": overhead_resistance.get("date"),
}
support_levels = compute_support_levels(daily_df, price_now, symbol, data_manager)
sma_200_val = finviz_overview.get("sma_200")
if sma_200_val and float(sma_200_val) < price_now:
support_levels.append(
{
"level": "sma_200",
"label": "SMA 200d",
"price": float(sma_200_val),
"source": "daily_adjusted",
}
)
support_levels.sort(key=lambda x: x["price"])
nearest_support = support_levels[-1] if support_levels else None
if nearest_support:
nearest_support = {
"price": nearest_support["price"],
"volume": nearest_support.get("volume"),
"date": nearest_support.get("date"),
}
volume_today = finviz_overview.get("volume", 0) or 0
avg_vol = finviz_overview.get("avg_volume", 0) or 0
rel_vol = round(volume_today / avg_vol, 1) if avg_vol else None
prev_close = finviz_overview.get("previous_close", 0) or 0
gap_today = round((price_now - prev_close) / prev_close * 100, 1) if prev_close else None
inst_own = finviz_overview.get("inst_own")
company_data = {
"country": finviz_overview.get("country", ""),
"city": finviz_overview.get("city", ""),
"phone": finviz_overview.get("phone", ""),
"business_description": company_description or finviz_overview.get("business_description", ""),
"officers": company_officers,
}
chinese_profile = score_chinese_profile(company_data)
themes = infer_themes(company_data["business_description"])
return {
"catalysts": {
"finviz": finviz_catalyst,
"next_earnings_date": next_earnings_date,
},
"company": {
"name": finviz_overview.get("company_name"),
"sector": finviz_overview.get("sector"),
"industry": finviz_overview.get("industry"),
"country": finviz_overview.get("country"),
"city": finviz_overview.get("city"),
"phone": finviz_overview.get("phone"),
"employees": finviz_overview.get("employees") or employees,
"business_description": company_description or finviz_overview.get("business_description", ""),
"chinese_profile": chinese_profile,
"themes": themes,
"nasdaq_non_compliant": nasdaq_non_compliant,
"sec_unlocks": sec_unlocks,
},
"metrics": {
"price": finviz_overview.get("price"),
"volume": finviz_overview.get("volume"),
"avg_volume": finviz_overview.get("avg_volume"),
"market_cap": finviz_overview.get("market_cap"),
"float_shares": finviz_overview.get("float_shares") or 0,
"float_shares_finviz": float_finviz or 0,
"rel_vol": rel_vol,
"gap_pct": gap_today,
"gap_history": gap_history,
"run_history": run_history,
"employees": employees,
"inst_own": inst_own,
"overhead_resistance": overhead_resistance,
"resistance_levels": resistance_levels,
"nearest_support": nearest_support,
"support_levels": support_levels,
},
"corporate_actions": {
"actions": corporate_actions,
"last_reverse_split": last_reverse_split,
"last_forward_split": last_forward_split,
},
"board": extract_board(company_officers),
"financials": {
"net_income": (financials or {}).get("net_income"),
"operating_cash_flow": (
(stockanalysis_data or {}).get("operating_cash_flow") or (financials or {}).get("operating_cash_flow")
),
"financing_cash_flow": (financials or {}).get("financing_cash_flow"),
},
"stockanalysis": stockanalysis_data or {},
"smallcaplab": smallcaplab_data or {},
"dilution_risk": dilution_risk or {},
"scl_dilution": (smallcaplab_data or {}).get("dilution_risk"),
"scl_survival": (smallcaplab_data or {}).get("survival"),
"press_releases": press_releases or [],
}
async def get_stock_catalysts(symbol: str, fast: bool = False) -> dict:
"""Stock catalysts with layered data fetching.
Layer 1 — Cache
Layer 2 — Fast: DuckDB + Finviz + StockAnalysis + SmallCapLab
Layer 3 — Background: Yahoo board + cashflow + SEC
When fast=True: skip StockAnalysis, SmallCapLab, press releases, insider,
and catalyst for a faster response. Result is not cached (the full call
handles caching).
"""
if not fast:
cached = cache_get(_cache_key_fn(symbol))
if cached is not None:
return cached
timeout = 5
loop = asyncio.get_event_loop()
# Fast tasks (always)
duckdb_task = loop.run_in_executor(_PROVIDER_EXECUTOR, fetch_duckdb_data, symbol)
finviz_quote_task = loop.run_in_executor(_PROVIDER_EXECUTOR, finviz.fetch_quote_page, symbol)
# Slow tasks (skipped in fast mode)
finviz_insider_task = None
finviz_catalyst_task = None
sa_task = None
scl_task = None
news_task = None
if not fast:
finviz_insider_task = loop.run_in_executor(_PROVIDER_EXECUTOR, finviz.fetch_insider_officers, symbol)
finviz_catalyst_task = loop.run_in_executor(_PROVIDER_EXECUTOR, finviz.fetch_catalyst, symbol)
sa_task = loop.run_in_executor(_PROVIDER_EXECUTOR, stockanalysis.fetch_statistics, symbol)
scl_task = loop.run_in_executor(_PROVIDER_EXECUTOR, smallcaplab.fetch, symbol)
news_task = loop.run_in_executor(_PROVIDER_EXECUTOR, fetch_press_release, symbol)
duckdb_data = await duckdb_task
finviz_quote = {}
catalyst = {}
company_officers = []
press_releases: list[dict] = []
try:
finviz_quote = await asyncio.wait_for(finviz_quote_task, timeout=timeout)
except Exception as e:
logger.warning(f"Finviz quote error for {symbol}: {e}")
if not fast:
try:
company_officers = await asyncio.wait_for(finviz_insider_task, timeout=timeout)
except Exception as e:
logger.warning(f"Finviz insider error for {symbol}: {e}")
try:
cat = await asyncio.wait_for(finviz_catalyst_task, timeout=timeout)
if cat and cat.get("has_catalyst"):
catalyst = cat
except Exception as e:
logger.warning(f"Finviz catalyst error for {symbol}: {e}")
try:
press_releases = await asyncio.wait_for(news_task, timeout=timeout)
except Exception as e:
logger.warning(f"Press releases error for {symbol}: {e}")
daily_df = duckdb_data.get("daily_df", pd.DataFrame())
# Split-adjust OHLC so 52w-high / SMA / support / resistance are computed
# on a consistent share-count scale (DefeatBeta-sourced splits).
daily_df = adjust_for_splits(daily_df, splits_provider.get_splits(symbol))
sma_200 = compute_sma(daily_df, 200, symbol, data_manager)
sma_50 = compute_sma(daily_df, 50, symbol, data_manager)
high_52w = get_52w_high(daily_df)
if high_52w is None:
high_52w = finviz_quote.get("52w_high")
price = finviz_quote.get("price") or (float(daily_df.iloc[-1]["close"]) if not daily_df.empty else 0)
prev_close = finviz_quote.get("previous_close") or price
finviz_overview = {
"symbol": symbol,
"company_name": finviz_quote.get("company_name", ""),
"market_cap": finviz_quote.get("market_cap", 0),
"price": price,
"previous_close": prev_close,
"volume": finviz_quote.get("volume", 0),
"avg_volume": finviz_quote.get("avg_volume", 0),
"float_shares": finviz_quote.get("float_shares", 0),
"sector": "",
"industry": "",
"country": "",
"city": "",
"phone": "",
"company_officers": company_officers,
"employees": finviz_quote.get("employees", 0),
"business_description": finviz_quote.get("company_description", ""),
"52w_high": high_52w,
"sma_200": sma_200,
"sma_50": sma_50,
"inst_own": finviz_quote.get("inst_own"),
}
try:
md = data_manager.metadata_df
if md is not None and not md.empty:
row = md[md["symbol"] == symbol]
if not row.empty:
r = row.iloc[0]
for col in ("sector", "industry", "country"):
val = r.get(col)
finviz_overview[col] = val if isinstance(val, str) and val else ""
except Exception:
pass
gap_history = duckdb_data.get("gap_history", [])
run_history = duckdb_data.get("run_history", [])
corporate_actions = get_corporate_actions_dict(symbol)
next_earnings_date = parse_earnings_date(finviz_quote.get("earnings_date_raw", ""))
nasdaq_non_compliant = None
if not fast:
try:
nc_task = loop.run_in_executor(_PROVIDER_EXECUTOR, non_compliant_info, symbol)
nc_info = await asyncio.wait_for(nc_task, timeout=timeout)
if nc_info:
nasdaq_non_compliant = nc_info
except Exception as e:
logger.warning(f"NASDAQ non-compliant error for {symbol}: {e}")
sec_unlocks = None
if not fast:
try:
su_task = loop.run_in_executor(_PROVIDER_EXECUTOR, fetch_sec_unlocks, symbol)
su_info = await asyncio.wait_for(su_task, timeout=timeout)
if su_info:
sec_unlocks = su_info
except Exception as e:
logger.warning(f"SEC unlock check error for {symbol}: {e}")
net_income = parse_finviz_financial_number(finviz_quote.get("snapshot_Income"))
financials = {}
dilution_risk = {}
if net_income is not None:
financials["net_income"] = net_income
sa_data = None
if not fast:
try:
sa_result = await asyncio.wait_for(sa_task, timeout=timeout)
if sa_result.operating_cash_flow is not None:
sa_data = {
"operating_cash_flow": sa_result.operating_cash_flow,
"free_cash_flow": sa_result.free_cash_flow,
"net_income": sa_result.net_income,
"revenue": sa_result.revenue,
"cash_and_equivalents": sa_result.cash_and_equivalents,
"total_debt": sa_result.total_debt,
"net_cash": sa_result.net_cash,
"book_value": sa_result.book_value,
"book_value_per_share": sa_result.book_value_per_share,
"pe_ratio": sa_result.pe_ratio,
"ps_ratio": sa_result.ps_ratio,
"pb_ratio": sa_result.pb_ratio,
"current_ratio": sa_result.current_ratio,
"debt_equity": sa_result.debt_equity,
"roe": sa_result.roe,
"roic": sa_result.roic,
"shares_outstanding": sa_result.shares_outstanding,
"shares_change_yoy": sa_result.shares_change_yoy,
"insider_percent": sa_result.insider_percent,
"institution_percent": sa_result.institution_percent,
"float_shares": sa_result.float_shares,
"short_interest": sa_result.short_interest,
"short_pct_float": sa_result.short_pct_float,
"short_pct_shares": sa_result.short_pct_shares,
"short_ratio": sa_result.short_ratio,
"altman_z_score": sa_result.altman_z_score,
"piotroski_f_score": sa_result.piotroski_f_score,
"earnings_date": sa_result.earnings_date,
"gross_margin": sa_result.gross_margin,
"profit_margin": sa_result.profit_margin,
"operating_margin": sa_result.operating_margin,
"beta": sa_result.beta,
"employee_count": sa_result.employee_count,
"market_cap": sa_result.market_cap,
"enterprise_value": sa_result.enterprise_value,
"working_capital": sa_result.working_capital,
}
if sa_result.earnings_date and not next_earnings_date:
next_earnings_date = sa_result.earnings_date
if financials.get("operating_cash_flow") is None:
financials["operating_cash_flow"] = sa_result.operating_cash_flow
except Exception as e:
logger.warning(f"StockAnalysis error for {symbol}: {e}")
scl_data = None
if not fast:
try:
scl_result = await asyncio.wait_for(scl_task, timeout=timeout)
if scl_result.dilution_risk or scl_result.survival or scl_result.dilution_activity:
scl_data = {
"dilution_risk": {
"rating": scl_result.dilution_risk.rating if scl_result.dilution_risk else None,
"structural_score": scl_result.dilution_risk.structural_score
if scl_result.dilution_risk
else None,
"near_term_score": scl_result.dilution_risk.near_term_score
if scl_result.dilution_risk
else None,
"severity_score": scl_result.dilution_risk.severity_score if scl_result.dilution_risk else None,
"has_atm_drip": scl_result.dilution_risk.has_atm_drip if scl_result.dilution_risk else None,
}
if scl_result.dilution_risk
else None,
"survival": {
"cash_on_hand": scl_result.survival.cash_on_hand if scl_result.survival else None,
"quarterly_burn": scl_result.survival.quarterly_burn if scl_result.survival else None,
"estimated_runway": scl_result.survival.estimated_runway if scl_result.survival else None,
"adtv_30d": scl_result.survival.adtv_30d if scl_result.survival else None,
}
if scl_result.survival
else None,
"dilution_activity": {
"yoy_share_growth_pct": scl_result.dilution_activity.yoy_share_growth_pct
if scl_result.dilution_activity
else None,
"share_cagr_2y_pct": scl_result.dilution_activity.share_cagr_2y_pct
if scl_result.dilution_activity
else None,
"capital_raises_12mo": scl_result.dilution_activity.capital_raises_12mo
if scl_result.dilution_activity
else None,
"capital_raises_24mo": scl_result.dilution_activity.capital_raises_24mo
if scl_result.dilution_activity
else None,
"total_raises": scl_result.dilution_activity.total_raises
if scl_result.dilution_activity
else None,
"last_raise_days_ago": scl_result.dilution_activity.last_raise_days_ago
if scl_result.dilution_activity
else None,
}
if scl_result.dilution_activity
else None,
"capital_structure": {
"charter_authorized_shares": scl_result.capital_structure.charter_authorized_shares
if scl_result.capital_structure
else None,
"shares_outstanding": scl_result.capital_structure.shares_outstanding
if scl_result.capital_structure
else None,
"unissued_headroom": scl_result.capital_structure.unissued_headroom
if scl_result.capital_structure
else None,
"has_atm": scl_result.capital_structure.has_atm if scl_result.capital_structure else None,
"has_shelf": scl_result.capital_structure.has_shelf if scl_result.capital_structure else None,
}
if scl_result.capital_structure
else None,
}
except Exception as e:
logger.warning(f"SmallCapLab error for {symbol}: {e}")
result = build_stock_result(
symbol=symbol,
finviz_overview=finviz_overview,
corporate_actions=corporate_actions,
next_earnings_date=next_earnings_date,
daily_df=daily_df,
gap_history=gap_history,
run_history=run_history,
company_description=finviz_quote.get("company_description", ""),
employees=finviz_quote.get("employees"),
float_finviz=finviz_quote.get("float_shares", 0) or 0,
nasdaq_non_compliant=nasdaq_non_compliant,
sec_unlocks=sec_unlocks,
financials=financials,
dilution_risk=dilution_risk,
stockanalysis_data=sa_data,
smallcaplab_data=scl_data,
press_releases=press_releases,
)
if catalyst:
result["catalysts"]["finviz"] = catalyst
if not fast:
cache_set(_cache_key_fn(symbol), result)
asyncio.create_task(background_refresh_slow_data(symbol))
return result
async def background_refresh_slow_data(symbol: str) -> None:
"""Background refresh: Yahoo board + cashflow + SEC dilution risk."""
try:
loop = asyncio.get_event_loop()
yahoo_task = loop.run_in_executor(_PROVIDER_EXECUTOR, defeatbeta.fetch_consolidated, symbol)
sec_task = loop.run_in_executor(_PROVIDER_EXECUTOR, fetch_sec_dilution_risk, symbol)
yahoo_data = await asyncio.wait_for(yahoo_task, timeout=15)
dr = await asyncio.wait_for(sec_task, timeout=15)
except Exception as e:
logger.warning(f"Background refresh error for {symbol}: {e}")
return
from core.chinese_profile import score_chinese_profile
from core.shared.cache import cache_get, cache_set
from core.shared.cache import cache_key as _ck
current = cache_get(_ck(symbol))
if current is None:
return
# Work on a COPY so concurrent readers of the cached object never
# observe a half-mutated dict mid-refresh.
current = dict(current)
fundamentals = yahoo_data.get("fundamentals", {})
company_officers = fundamentals.get("company_officers", [])
city = fundamentals.get("city", "")
phone = fundamentals.get("phone", "")
cashflow = yahoo_data.get("cashflow", {})
updated = False
if company_officers:
current["board"] = extract_board(company_officers)
updated_company = dict(current.get("company", {}))
has_company_update = False
if city:
updated_company["city"] = city
has_company_update = True
if phone:
updated_company["phone"] = phone
has_company_update = True
if company_officers and has_company_update:
updated_company["chinese_profile"] = score_chinese_profile(
{
"country": updated_company.get("country", ""),
"city": city or updated_company.get("city", ""),
"phone": phone or updated_company.get("phone", ""),
"business_description": updated_company.get("business_description", ""),
"officers": company_officers,
}
)
if has_company_update:
current["company"] = updated_company
updated = True
current_fin = dict(current.get("financials", {}))
current_ocf = current_fin.get("operating_cash_flow")
fin_updated = False
if cashflow:
if "net_income" not in current_fin and cashflow.get("net_income") is not None:
current_fin["net_income"] = cashflow["net_income"]
fin_updated = True
if current_ocf is None and cashflow.get("operating_cash_flow") is not None:
current_fin["operating_cash_flow"] = cashflow["operating_cash_flow"]
fin_updated = True
if cashflow.get("financing_cash_flow") is not None:
current_fin["financing_cash_flow"] = cashflow["financing_cash_flow"]
fin_updated = True
if fin_updated:
current["financials"] = current_fin
updated = True
if dr:
current["dilution_risk"] = dr
updated = True
if updated:
cache_set(_ck(symbol), current)