Add configurable data providers and localize frontend UI
This commit is contained in:
@@ -29,8 +29,10 @@ from backend.tools.data_tools import (
|
||||
prices_to_df,
|
||||
search_line_items,
|
||||
)
|
||||
from backend.tools.technical_signals import StockTechnicalAnalyzer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_technical_analyzer = StockTechnicalAnalyzer()
|
||||
|
||||
|
||||
def _to_text_response(text: str) -> ToolResponse:
|
||||
@@ -108,7 +110,12 @@ def _fmt(val, fmt=".2f", suffix="") -> str:
|
||||
|
||||
def _resolved_date(current_date: Optional[str]) -> str:
|
||||
"""Ensure we always return a concrete date string."""
|
||||
return current_date or datetime.today().strftime("%Y-%m-%d")
|
||||
today = datetime.today().date()
|
||||
if not current_date:
|
||||
return today.strftime("%Y-%m-%d")
|
||||
|
||||
requested_date = datetime.strptime(current_date, "%Y-%m-%d").date()
|
||||
return min(requested_date, today).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
# ==================== Fundamental Analysis Tools ====================
|
||||
@@ -419,60 +426,33 @@ def analyze_trend_following(
|
||||
lines.append(f"{ticker}: Insufficient price data\n")
|
||||
continue
|
||||
|
||||
df = prices_to_df(prices)
|
||||
n = len(df)
|
||||
|
||||
# Calculate moving averages
|
||||
sma_20_win = min(20, n // 2)
|
||||
sma_50_win = min(50, n - 5) if n > 25 else min(25, n - 5)
|
||||
sma_200_win = min(200, n - 10) if n > 200 else None
|
||||
|
||||
df["SMA_20"] = df["close"].rolling(window=sma_20_win).mean()
|
||||
df["SMA_50"] = df["close"].rolling(window=sma_50_win).mean()
|
||||
if sma_200_win:
|
||||
df["SMA_200"] = df["close"].rolling(window=sma_200_win).mean()
|
||||
|
||||
df["EMA_12"] = df["close"].ewm(span=min(12, n // 3)).mean()
|
||||
df["EMA_26"] = df["close"].ewm(span=min(26, n // 2)).mean()
|
||||
df["MACD"] = df["EMA_12"] - df["EMA_26"]
|
||||
df["MACD_signal"] = df["MACD"].ewm(span=9).mean()
|
||||
|
||||
current_price = _safe_float(df["close"].iloc[-1])
|
||||
sma_20 = _safe_float(df["SMA_20"].iloc[-1])
|
||||
sma_50 = _safe_float(df["SMA_50"].iloc[-1])
|
||||
sma_200 = (
|
||||
_safe_float(df["SMA_200"].iloc[-1])
|
||||
if "SMA_200" in df.columns
|
||||
signal = _technical_analyzer.analyze(ticker, prices_to_df(prices))
|
||||
distance_200ma = (
|
||||
((signal.current_price - signal.ma200) / signal.ma200) * 100
|
||||
if signal.ma200
|
||||
else None
|
||||
)
|
||||
macd = _safe_float(df["MACD"].iloc[-1])
|
||||
macd_signal = _safe_float(df["MACD_signal"].iloc[-1])
|
||||
macd_signal_str = (
|
||||
"BUY" if signal.macd > signal.macd_signal else "SELL"
|
||||
)
|
||||
|
||||
# Determine trend
|
||||
if sma_200:
|
||||
trend = "BULLISH" if current_price > sma_200 else "BEARISH"
|
||||
distance_200ma = ((current_price - sma_200) / sma_200) * 100
|
||||
else:
|
||||
trend = "UNKNOWN"
|
||||
distance_200ma = None
|
||||
|
||||
macd_signal_str = "BUY" if macd > macd_signal else "SELL"
|
||||
|
||||
lines.append(f"{ticker}: ${current_price:.2f}")
|
||||
lines.append(f"{ticker}: ${signal.current_price:.2f}")
|
||||
lines.append(
|
||||
f" SMA20: ${sma_20:.2f} | SMA50: ${sma_50:.2f} | SMA200: {f'${sma_200:.2f}' if sma_200 else 'N/A'}",
|
||||
f" MA20: ${signal.ma20:.2f} | MA50: ${signal.ma50:.2f} | MA200: {f'${signal.ma200:.2f}' if signal.ma200 else 'N/A'}",
|
||||
)
|
||||
lines.append(
|
||||
f" MACD: {macd:.3f} | Signal: {macd_signal:.3f} -> {macd_signal_str}",
|
||||
f" MACD: {signal.macd:.3f} | Signal: {signal.macd_signal:.3f} -> {macd_signal_str}",
|
||||
)
|
||||
lines.append(
|
||||
f" Long-term Trend: {trend}"
|
||||
f" Long-term Trend: {signal.trend}"
|
||||
+ (
|
||||
f" ({distance_200ma:+.1f}% from 200MA)"
|
||||
if distance_200ma
|
||||
else ""
|
||||
),
|
||||
)
|
||||
if signal.notes:
|
||||
lines.append(f" Notes: {'; '.join(signal.notes)}")
|
||||
lines.append("")
|
||||
|
||||
return _to_text_response("\n".join(lines))
|
||||
@@ -515,51 +495,29 @@ def analyze_mean_reversion(
|
||||
lines.append(f"{ticker}: Insufficient price data\n")
|
||||
continue
|
||||
|
||||
df = prices_to_df(prices)
|
||||
n = len(df)
|
||||
signal = _technical_analyzer.analyze(ticker, prices_to_df(prices))
|
||||
deviation = (
|
||||
((signal.current_price - signal.bollinger_mid) / signal.bollinger_mid)
|
||||
* 100
|
||||
if signal.bollinger_mid > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
# Bollinger Bands
|
||||
window = min(20, n - 2)
|
||||
df["SMA"] = df["close"].rolling(window=window).mean()
|
||||
df["STD"] = df["close"].rolling(window=window).std()
|
||||
df["Upper_Band"] = df["SMA"] + (2 * df["STD"])
|
||||
df["Lower_Band"] = df["SMA"] - (2 * df["STD"])
|
||||
|
||||
# RSI
|
||||
delta = df["close"].diff()
|
||||
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
|
||||
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
|
||||
rs = gain / loss
|
||||
df["RSI"] = 100 - (100 / (1 + rs))
|
||||
|
||||
current_price = _safe_float(df["close"].iloc[-1])
|
||||
sma = _safe_float(df["SMA"].iloc[-1])
|
||||
upper = _safe_float(df["Upper_Band"].iloc[-1])
|
||||
lower = _safe_float(df["Lower_Band"].iloc[-1])
|
||||
rsi = _safe_float(df["RSI"].iloc[-1])
|
||||
deviation = (current_price - sma) / sma * 100
|
||||
|
||||
# Signal interpretation
|
||||
if rsi > 70:
|
||||
rsi_signal = "OVERBOUGHT"
|
||||
elif rsi < 30:
|
||||
rsi_signal = "OVERSOLD"
|
||||
else:
|
||||
rsi_signal = "NEUTRAL"
|
||||
|
||||
if current_price > upper:
|
||||
if signal.current_price > signal.bollinger_upper > 0:
|
||||
bb_signal = "ABOVE UPPER BAND (potential sell)"
|
||||
elif current_price < lower:
|
||||
elif 0 < signal.current_price < signal.bollinger_lower:
|
||||
bb_signal = "BELOW LOWER BAND (potential buy)"
|
||||
else:
|
||||
bb_signal = "WITHIN BANDS"
|
||||
|
||||
lines.append(f"{ticker}: ${current_price:.2f}")
|
||||
lines.append(f"{ticker}: ${signal.current_price:.2f}")
|
||||
lines.append(
|
||||
f" Bollinger: Lower ${lower:.2f} | SMA ${sma:.2f} | Upper ${upper:.2f}",
|
||||
f" Bollinger: Lower ${signal.bollinger_lower:.2f} | Mid ${signal.bollinger_mid:.2f} | Upper ${signal.bollinger_upper:.2f}",
|
||||
)
|
||||
lines.append(f" Position: {bb_signal}")
|
||||
lines.append(f" RSI: {rsi:.1f} -> {rsi_signal}")
|
||||
lines.append(
|
||||
f" RSI: {signal.rsi14:.1f} -> {signal.mean_reversion_signal}",
|
||||
)
|
||||
lines.append(f" Price Deviation from SMA: {deviation:+.1f}%")
|
||||
lines.append("")
|
||||
|
||||
@@ -602,61 +560,30 @@ def analyze_momentum(
|
||||
lines.append(f"{ticker}: Insufficient price data\n")
|
||||
continue
|
||||
|
||||
df = prices_to_df(prices)
|
||||
n = len(df)
|
||||
df["returns"] = df["close"].pct_change()
|
||||
signal = _technical_analyzer.analyze(ticker, prices_to_df(prices))
|
||||
|
||||
# Adaptive periods
|
||||
short_p = min(5, n // 3)
|
||||
med_p = min(10, n // 2)
|
||||
long_p = min(20, n - 2)
|
||||
|
||||
current_price = _safe_float(df["close"].iloc[-1])
|
||||
mom_5 = (
|
||||
_safe_float(
|
||||
(df["close"].iloc[-1] / df["close"].iloc[-short_p - 1] - 1)
|
||||
* 100,
|
||||
)
|
||||
if n > short_p
|
||||
else 0
|
||||
)
|
||||
mom_10 = (
|
||||
_safe_float(
|
||||
(df["close"].iloc[-1] / df["close"].iloc[-med_p - 1] - 1)
|
||||
* 100,
|
||||
)
|
||||
if n > med_p
|
||||
else 0
|
||||
)
|
||||
mom_20 = (
|
||||
_safe_float(
|
||||
(df["close"].iloc[-1] / df["close"].iloc[-long_p - 1] - 1)
|
||||
* 100,
|
||||
)
|
||||
if n > long_p
|
||||
else 0
|
||||
)
|
||||
volatility = _safe_float(
|
||||
df["returns"].tail(20).std() * np.sqrt(252) * 100,
|
||||
)
|
||||
|
||||
# Overall momentum signal
|
||||
avg_mom = (mom_5 + mom_10 + mom_20) / 3
|
||||
avg_mom = (
|
||||
signal.momentum_5d_pct
|
||||
+ signal.momentum_10d_pct
|
||||
+ signal.momentum_20d_pct
|
||||
) / 3
|
||||
if avg_mom > 2:
|
||||
signal = "STRONG BULLISH"
|
||||
signal_text = "STRONG BULLISH"
|
||||
elif avg_mom > 0:
|
||||
signal = "BULLISH"
|
||||
signal_text = "BULLISH"
|
||||
elif avg_mom > -2:
|
||||
signal = "BEARISH"
|
||||
signal_text = "BEARISH"
|
||||
else:
|
||||
signal = "STRONG BEARISH"
|
||||
signal_text = "STRONG BEARISH"
|
||||
|
||||
lines.append(f"{ticker}: ${current_price:.2f}")
|
||||
lines.append(f"{ticker}: ${signal.current_price:.2f}")
|
||||
lines.append(
|
||||
f" 5-day: {mom_5:+.1f}% | 10-day: {mom_10:+.1f}% | 20-day: {mom_20:+.1f}%",
|
||||
f" 5-day: {signal.momentum_5d_pct:+.1f}% | 10-day: {signal.momentum_10d_pct:+.1f}% | 20-day: {signal.momentum_20d_pct:+.1f}%",
|
||||
)
|
||||
lines.append(f" Volatility (annualized): {volatility:.1f}%")
|
||||
lines.append(f" Overall: {signal}")
|
||||
lines.append(
|
||||
f" Volatility (annualized): {signal.annualized_volatility_pct:.1f}%",
|
||||
)
|
||||
lines.append(f" Overall: {signal_text}")
|
||||
lines.append("")
|
||||
|
||||
return _to_text_response("\n".join(lines))
|
||||
@@ -699,38 +626,26 @@ def analyze_volatility(
|
||||
continue
|
||||
|
||||
df = prices_to_df(prices)
|
||||
n = len(df)
|
||||
df["returns"] = df["close"].pct_change()
|
||||
|
||||
# Adaptive windows
|
||||
short_w = min(10, n // 2)
|
||||
med_w = min(20, n - 2)
|
||||
long_w = min(60, n - 1) if n > 30 else med_w
|
||||
|
||||
current_price = _safe_float(df["close"].iloc[-1])
|
||||
signal = _technical_analyzer.analyze(ticker, df)
|
||||
returns = df["close"].pct_change()
|
||||
short_w = min(10, max(1, len(df) - 1))
|
||||
med_w = min(20, max(1, len(df) - 1))
|
||||
long_w = min(60, max(1, len(df) - 1))
|
||||
vol_10 = _safe_float(
|
||||
df["returns"].tail(short_w).std() * np.sqrt(252) * 100,
|
||||
returns.tail(short_w).std() * np.sqrt(252) * 100,
|
||||
)
|
||||
vol_20 = _safe_float(
|
||||
df["returns"].tail(med_w).std() * np.sqrt(252) * 100,
|
||||
returns.tail(med_w).std() * np.sqrt(252) * 100,
|
||||
)
|
||||
vol_60 = _safe_float(
|
||||
df["returns"].tail(long_w).std() * np.sqrt(252) * 100,
|
||||
returns.tail(long_w).std() * np.sqrt(252) * 100,
|
||||
)
|
||||
|
||||
# Risk assessment
|
||||
if vol_20 > 50:
|
||||
risk = "HIGH RISK"
|
||||
elif vol_20 > 25:
|
||||
risk = "MODERATE RISK"
|
||||
else:
|
||||
risk = "LOW RISK"
|
||||
|
||||
lines.append(f"{ticker}: ${current_price:.2f}")
|
||||
lines.append(f"{ticker}: ${signal.current_price:.2f}")
|
||||
lines.append(
|
||||
f" 10-day Vol: {vol_10:.1f}% | 20-day Vol: {vol_20:.1f}% | 60-day Vol: {vol_60:.1f}%",
|
||||
)
|
||||
lines.append(f" Risk Level: {risk}")
|
||||
lines.append(f" Risk Level: {signal.risk_level}")
|
||||
lines.append("")
|
||||
|
||||
return _to_text_response("\n".join(lines))
|
||||
|
||||
@@ -1,43 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# flake8: noqa: E501
|
||||
# pylint: disable=C0301
|
||||
"""
|
||||
Data fetching tools for financial data.
|
||||
|
||||
All functions use centralized data source configuration from data_config.py.
|
||||
The data source is automatically determined based on available API keys:
|
||||
- Priority: FINNHUB_API_KEY > FINANCIAL_DATASETS_API_KEY
|
||||
"""
|
||||
"""Data fetching tools backed by the unified provider router."""
|
||||
import datetime
|
||||
import time
|
||||
|
||||
import finnhub
|
||||
import pandas as pd
|
||||
import pandas_market_calendars as mcal
|
||||
import requests
|
||||
from backend.data.provider_utils import normalize_symbol
|
||||
|
||||
from backend.config.data_config import (
|
||||
get_config,
|
||||
get_api_key,
|
||||
)
|
||||
from backend.data.cache import get_cache
|
||||
from backend.data.provider_router import get_provider_router
|
||||
from backend.data.schema import (
|
||||
CompanyFactsResponse,
|
||||
CompanyNews,
|
||||
CompanyNewsResponse,
|
||||
FinancialMetrics,
|
||||
FinancialMetricsResponse,
|
||||
InsiderTrade,
|
||||
InsiderTradeResponse,
|
||||
LineItem,
|
||||
LineItemResponse,
|
||||
Price,
|
||||
PriceResponse,
|
||||
)
|
||||
from backend.utils.settlement import logger
|
||||
|
||||
# Global cache instance
|
||||
_cache = get_cache()
|
||||
_router = get_provider_router()
|
||||
|
||||
|
||||
def get_last_tradeday(date: str) -> str:
|
||||
@@ -94,48 +77,6 @@ def get_last_tradeday(date: str) -> str:
|
||||
return prev_date.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _make_api_request(
|
||||
url: str,
|
||||
headers: dict,
|
||||
method: str = "GET",
|
||||
json_data: dict = None,
|
||||
max_retries: int = 3,
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Make an API request with rate limiting handling and moderate backoff.
|
||||
|
||||
Args:
|
||||
url: The URL to request
|
||||
headers: Headers to include in the request
|
||||
method: HTTP method (GET or POST)
|
||||
json_data: JSON data for POST requests
|
||||
max_retries: Maximum number of retries (default: 3)
|
||||
|
||||
Returns:
|
||||
requests.Response: The response object
|
||||
|
||||
Raises:
|
||||
Exception: If the request fails with a non-429 error
|
||||
"""
|
||||
for attempt in range(max_retries + 1): # +1 for initial attempt
|
||||
if method.upper() == "POST":
|
||||
response = requests.post(url, headers=headers, json=json_data)
|
||||
else:
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 429 and attempt < max_retries:
|
||||
# Linear backoff: 60s, 90s, 120s, 150s...
|
||||
delay = 60 + (30 * attempt)
|
||||
print(
|
||||
f"Rate limited (429). Attempt {attempt + 1}/{max_retries + 1}. Waiting {delay}s before retrying...",
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
|
||||
# Return the response (whether success, other errors, or final 429)
|
||||
return response
|
||||
|
||||
|
||||
def get_prices(
|
||||
ticker: str,
|
||||
start_date: str,
|
||||
@@ -154,75 +95,19 @@ def get_prices(
|
||||
Returns:
|
||||
list[Price]: List of Price objects
|
||||
"""
|
||||
config = get_config()
|
||||
data_source = config.source
|
||||
api_key = config.api_key
|
||||
ticker = normalize_symbol(ticker)
|
||||
cached_sources = _router.price_sources()
|
||||
for source in cached_sources:
|
||||
cache_key = f"{ticker}_{start_date}_{end_date}_{source}"
|
||||
if cached_data := _cache.get_prices(cache_key):
|
||||
return [Price(**price) for price in cached_data]
|
||||
|
||||
# Create a cache key that includes all parameters to ensure exact matches
|
||||
cache_key = f"{ticker}_{start_date}_{end_date}_{data_source}"
|
||||
|
||||
# Check cache first - simple exact match
|
||||
if cached_data := _cache.get_prices(cache_key):
|
||||
return [Price(**price) for price in cached_data]
|
||||
|
||||
prices = []
|
||||
|
||||
if data_source == "finnhub":
|
||||
# Use Finnhub API
|
||||
client = finnhub.Client(api_key=api_key)
|
||||
|
||||
# Convert dates to timestamps
|
||||
start_timestamp = int(
|
||||
datetime.datetime.strptime(start_date, "%Y-%m-%d").timestamp(),
|
||||
)
|
||||
end_timestamp = int(
|
||||
(
|
||||
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
||||
+ datetime.timedelta(days=1)
|
||||
).timestamp(),
|
||||
)
|
||||
|
||||
# Fetch candle data from Finnhub
|
||||
candles = client.stock_candles(
|
||||
ticker,
|
||||
"D",
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
)
|
||||
|
||||
# Convert to Price objects
|
||||
for i in range(len(candles["t"])):
|
||||
price = Price(
|
||||
open=candles["o"][i],
|
||||
close=candles["c"][i],
|
||||
high=candles["h"][i],
|
||||
low=candles["l"][i],
|
||||
volume=int(candles["v"][i]),
|
||||
time=datetime.datetime.fromtimestamp(candles["t"][i]).strftime(
|
||||
"%Y-%m-%d",
|
||||
),
|
||||
)
|
||||
prices.append(price)
|
||||
|
||||
else: # financial_datasets
|
||||
# Use Financial Datasets API
|
||||
headers = {"X-API-KEY": api_key}
|
||||
|
||||
url = f"https://api.financialdatasets.ai/prices/?ticker={ticker}&interval=day&interval_multiplier=1&start_date={start_date}&end_date={end_date}"
|
||||
response = _make_api_request(url, headers)
|
||||
if response.status_code != 200:
|
||||
raise ValueError(
|
||||
f"Error fetching data: {ticker} - {response.status_code} - {response.text}",
|
||||
)
|
||||
|
||||
# Parse response with Pydantic model
|
||||
price_response = PriceResponse(**response.json())
|
||||
prices = price_response.prices
|
||||
prices, data_source = _router.get_prices(ticker, start_date, end_date)
|
||||
|
||||
if not prices:
|
||||
return []
|
||||
|
||||
# Cache the results using the comprehensive cache key
|
||||
cache_key = f"{ticker}_{start_date}_{end_date}_{data_source}"
|
||||
_cache.set_prices(cache_key, [p.model_dump() for p in prices])
|
||||
return prices
|
||||
|
||||
@@ -247,119 +132,29 @@ def get_financial_metrics(
|
||||
Returns:
|
||||
list[FinancialMetrics]: List of financial metrics
|
||||
"""
|
||||
config = get_config()
|
||||
data_source = config.source
|
||||
api_key = config.api_key
|
||||
ticker = normalize_symbol(ticker)
|
||||
for source in _router.api_sources():
|
||||
cache_key = f"{ticker}_{period}_{end_date}_{limit}_{source}"
|
||||
if cached_data := _cache.get_financial_metrics(cache_key):
|
||||
return [FinancialMetrics(**metric) for metric in cached_data]
|
||||
|
||||
# Create a cache key that includes all parameters to ensure exact matches
|
||||
cache_key = f"{ticker}_{period}_{end_date}_{limit}_{data_source}"
|
||||
|
||||
# Check cache first - simple exact match
|
||||
if cached_data := _cache.get_financial_metrics(cache_key):
|
||||
return [FinancialMetrics(**metric) for metric in cached_data]
|
||||
|
||||
financial_metrics = []
|
||||
|
||||
if data_source == "finnhub":
|
||||
# Use Finnhub API - Basic Financials
|
||||
client = finnhub.Client(api_key=api_key)
|
||||
|
||||
# Fetch basic financials from Finnhub
|
||||
# metric='all' returns all available metrics
|
||||
financials = client.company_basic_financials(ticker, "all")
|
||||
|
||||
if not financials or "metric" not in financials:
|
||||
return []
|
||||
|
||||
# Finnhub returns {series: {...}, metric: {...}, metricType: ..., symbol: ...}
|
||||
# We need to create a FinancialMetrics object from this
|
||||
metric_data = financials.get("metric", {})
|
||||
|
||||
# Create a FinancialMetrics object with available data
|
||||
metric = _map_finnhub_metrics(ticker, end_date, period, metric_data)
|
||||
|
||||
financial_metrics = [metric]
|
||||
|
||||
else: # financial_datasets
|
||||
# Use Financial Datasets API
|
||||
headers = {"X-API-KEY": api_key}
|
||||
|
||||
url = f"https://api.financialdatasets.ai/financial-metrics/?ticker={ticker}&report_period_lte={end_date}&limit={limit}&period={period}"
|
||||
response = _make_api_request(url, headers)
|
||||
if response.status_code != 200:
|
||||
raise ValueError(
|
||||
f"Error fetching data: {ticker} - {response.status_code} - {response.text}",
|
||||
)
|
||||
|
||||
# Parse response with Pydantic model
|
||||
metrics_response = FinancialMetricsResponse(**response.json())
|
||||
financial_metrics = metrics_response.financial_metrics
|
||||
financial_metrics, data_source = _router.get_financial_metrics(
|
||||
ticker=ticker,
|
||||
end_date=end_date,
|
||||
period=period,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if not financial_metrics:
|
||||
return []
|
||||
|
||||
# Cache the results as dicts using the comprehensive cache key
|
||||
cache_key = f"{ticker}_{period}_{end_date}_{limit}_{data_source}"
|
||||
_cache.set_financial_metrics(
|
||||
cache_key,
|
||||
[m.model_dump() for m in financial_metrics],
|
||||
)
|
||||
return financial_metrics
|
||||
|
||||
|
||||
def _map_finnhub_metrics(
|
||||
ticker: str,
|
||||
end_date: str,
|
||||
period: str,
|
||||
metric_data: dict,
|
||||
) -> FinancialMetrics:
|
||||
"""Map Finnhub metric data to FinancialMetrics model."""
|
||||
return FinancialMetrics(
|
||||
ticker=ticker,
|
||||
report_period=end_date,
|
||||
period=period,
|
||||
currency="USD",
|
||||
market_cap=metric_data.get("marketCapitalization"),
|
||||
enterprise_value=None,
|
||||
price_to_earnings_ratio=metric_data.get("peBasicExclExtraTTM"),
|
||||
price_to_book_ratio=metric_data.get("pbAnnual"),
|
||||
price_to_sales_ratio=metric_data.get("psAnnual"),
|
||||
enterprise_value_to_ebitda_ratio=None,
|
||||
enterprise_value_to_revenue_ratio=None,
|
||||
free_cash_flow_yield=None,
|
||||
peg_ratio=None,
|
||||
gross_margin=metric_data.get("grossMarginTTM"),
|
||||
operating_margin=metric_data.get("operatingMarginTTM"),
|
||||
net_margin=metric_data.get("netProfitMarginTTM"),
|
||||
return_on_equity=metric_data.get("roeTTM"),
|
||||
return_on_assets=metric_data.get("roaTTM"),
|
||||
return_on_invested_capital=metric_data.get("roicTTM"),
|
||||
asset_turnover=metric_data.get("assetTurnoverTTM"),
|
||||
inventory_turnover=metric_data.get("inventoryTurnoverTTM"),
|
||||
receivables_turnover=metric_data.get("receivablesTurnoverTTM"),
|
||||
days_sales_outstanding=None,
|
||||
operating_cycle=None,
|
||||
working_capital_turnover=None,
|
||||
current_ratio=metric_data.get("currentRatioAnnual"),
|
||||
quick_ratio=metric_data.get("quickRatioAnnual"),
|
||||
cash_ratio=None,
|
||||
operating_cash_flow_ratio=None,
|
||||
debt_to_equity=metric_data.get("totalDebt/totalEquityAnnual"),
|
||||
debt_to_assets=None,
|
||||
interest_coverage=None,
|
||||
revenue_growth=metric_data.get("revenueGrowthTTMYoy"),
|
||||
earnings_growth=None,
|
||||
book_value_growth=None,
|
||||
earnings_per_share_growth=metric_data.get("epsGrowthTTMYoy"),
|
||||
free_cash_flow_growth=None,
|
||||
operating_income_growth=None,
|
||||
ebitda_growth=None,
|
||||
payout_ratio=metric_data.get("payoutRatioAnnual"),
|
||||
earnings_per_share=metric_data.get("epsBasicExclExtraItemsTTM"),
|
||||
book_value_per_share=metric_data.get("bookValuePerShareAnnual"),
|
||||
free_cash_flow_per_share=None,
|
||||
)
|
||||
|
||||
|
||||
def search_line_items(
|
||||
ticker: str,
|
||||
line_items: list[str],
|
||||
@@ -373,123 +168,20 @@ def search_line_items(
|
||||
Returns empty list on API errors to allow graceful degradation.
|
||||
"""
|
||||
try:
|
||||
api_key = get_api_key()
|
||||
headers = {"X-API-KEY": api_key}
|
||||
|
||||
url = "https://api.financialdatasets.ai/financials/search/line-items"
|
||||
body = {
|
||||
"tickers": [ticker],
|
||||
"line_items": line_items,
|
||||
"end_date": end_date,
|
||||
"period": period,
|
||||
"limit": limit,
|
||||
}
|
||||
response = _make_api_request(
|
||||
url,
|
||||
headers,
|
||||
method="POST",
|
||||
json_data=body,
|
||||
ticker = normalize_symbol(ticker)
|
||||
return _router.search_line_items(
|
||||
ticker=ticker,
|
||||
line_items=line_items,
|
||||
end_date=end_date,
|
||||
period=period,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.info(
|
||||
f"Warning: Failed to fetch line items for {ticker}: "
|
||||
f"{response.status_code} - {response.text}",
|
||||
)
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
response_model = LineItemResponse(**data)
|
||||
search_results = response_model.search_results
|
||||
|
||||
if not search_results:
|
||||
return []
|
||||
|
||||
return search_results[:limit]
|
||||
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f"Warning: Exception while fetching line items for {ticker}: {str(e)}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _fetch_finnhub_insider_trades(
|
||||
ticker: str,
|
||||
start_date: str | None,
|
||||
end_date: str,
|
||||
limit: int,
|
||||
api_key: str,
|
||||
) -> list[InsiderTrade]:
|
||||
"""Fetch insider trades from Finnhub API."""
|
||||
client = finnhub.Client(api_key=api_key)
|
||||
|
||||
from_date = start_date or (
|
||||
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
||||
- datetime.timedelta(days=365)
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
insider_data = client.stock_insider_transactions(
|
||||
ticker,
|
||||
from_date,
|
||||
end_date,
|
||||
)
|
||||
|
||||
if not insider_data or "data" not in insider_data:
|
||||
return []
|
||||
|
||||
return [
|
||||
_convert_finnhub_insider_trade(ticker, trade)
|
||||
for trade in insider_data["data"][:limit]
|
||||
]
|
||||
|
||||
|
||||
def _fetch_fd_insider_trades(
|
||||
ticker: str,
|
||||
start_date: str | None,
|
||||
end_date: str,
|
||||
limit: int,
|
||||
api_key: str,
|
||||
) -> list[InsiderTrade]:
|
||||
"""Fetch insider trades from Financial Datasets API."""
|
||||
headers = {"X-API-KEY": api_key}
|
||||
all_trades = []
|
||||
current_end_date = end_date
|
||||
|
||||
while True:
|
||||
url = f"https://api.financialdatasets.ai/insider-trades/?ticker={ticker}&filing_date_lte={current_end_date}"
|
||||
if start_date:
|
||||
url += f"&filing_date_gte={start_date}"
|
||||
url += f"&limit={limit}"
|
||||
|
||||
response = _make_api_request(url, headers)
|
||||
if response.status_code != 200:
|
||||
raise ValueError(
|
||||
f"Error fetching data: {ticker} - {response.status_code} - {response.text}",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
response_model = InsiderTradeResponse(**data)
|
||||
insider_trades = response_model.insider_trades
|
||||
|
||||
if not insider_trades:
|
||||
break
|
||||
|
||||
all_trades.extend(insider_trades)
|
||||
|
||||
if not start_date or len(insider_trades) < limit:
|
||||
break
|
||||
|
||||
current_end_date = min(
|
||||
trade.filing_date for trade in insider_trades
|
||||
).split("T")[0]
|
||||
|
||||
if current_end_date <= start_date:
|
||||
break
|
||||
|
||||
return all_trades
|
||||
|
||||
|
||||
def get_insider_trades(
|
||||
ticker: str,
|
||||
end_date: str,
|
||||
@@ -497,133 +189,28 @@ def get_insider_trades(
|
||||
limit: int = 1000,
|
||||
) -> list[InsiderTrade]:
|
||||
"""Fetch insider trades from cache or API."""
|
||||
config = get_config()
|
||||
data_source = config.source
|
||||
api_key = config.api_key
|
||||
ticker = normalize_symbol(ticker)
|
||||
for source in _router.api_sources():
|
||||
cache_key = (
|
||||
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{source}"
|
||||
)
|
||||
if cached_data := _cache.get_insider_trades(cache_key):
|
||||
return [InsiderTrade(**trade) for trade in cached_data]
|
||||
|
||||
cache_key = (
|
||||
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{data_source}"
|
||||
all_trades, data_source = _router.get_insider_trades(
|
||||
ticker=ticker,
|
||||
end_date=end_date,
|
||||
start_date=start_date,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if cached_data := _cache.get_insider_trades(cache_key):
|
||||
return [InsiderTrade(**trade) for trade in cached_data]
|
||||
|
||||
if data_source == "finnhub":
|
||||
all_trades = _fetch_finnhub_insider_trades(
|
||||
ticker,
|
||||
start_date,
|
||||
end_date,
|
||||
limit,
|
||||
api_key,
|
||||
)
|
||||
else:
|
||||
all_trades = _fetch_fd_insider_trades(
|
||||
ticker,
|
||||
start_date,
|
||||
end_date,
|
||||
limit,
|
||||
api_key,
|
||||
)
|
||||
|
||||
if not all_trades:
|
||||
return []
|
||||
|
||||
_cache.set_insider_trades(
|
||||
cache_key,
|
||||
[trade.model_dump() for trade in all_trades],
|
||||
)
|
||||
cache_key = f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{data_source}"
|
||||
_cache.set_insider_trades(cache_key, [trade.model_dump() for trade in all_trades])
|
||||
return all_trades
|
||||
|
||||
|
||||
def _fetch_finnhub_company_news(
|
||||
ticker: str,
|
||||
start_date: str | None,
|
||||
end_date: str,
|
||||
limit: int,
|
||||
api_key: str,
|
||||
) -> list[CompanyNews]:
|
||||
"""Fetch company news from Finnhub API."""
|
||||
client = finnhub.Client(api_key=api_key)
|
||||
|
||||
from_date = start_date or (
|
||||
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
||||
- datetime.timedelta(days=30)
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
news_data = client.company_news(ticker, _from=from_date, to=end_date)
|
||||
|
||||
if not news_data:
|
||||
return []
|
||||
|
||||
all_news = []
|
||||
for news_item in news_data[:limit]:
|
||||
company_news = CompanyNews(
|
||||
ticker=ticker,
|
||||
title=news_item.get("headline", ""),
|
||||
related=news_item.get("related", ""),
|
||||
source=news_item.get("source", ""),
|
||||
date=(
|
||||
datetime.datetime.fromtimestamp(
|
||||
news_item.get("datetime", 0),
|
||||
datetime.timezone.utc,
|
||||
).strftime("%Y-%m-%d")
|
||||
if news_item.get("datetime")
|
||||
else None
|
||||
),
|
||||
url=news_item.get("url", ""),
|
||||
summary=news_item.get("summary", ""),
|
||||
category=news_item.get("category", ""),
|
||||
)
|
||||
all_news.append(company_news)
|
||||
return all_news
|
||||
|
||||
|
||||
def _fetch_fd_company_news(
|
||||
ticker: str,
|
||||
start_date: str | None,
|
||||
end_date: str,
|
||||
limit: int,
|
||||
api_key: str,
|
||||
) -> list[CompanyNews]:
|
||||
"""Fetch company news from Financial Datasets API."""
|
||||
headers = {"X-API-KEY": api_key}
|
||||
all_news = []
|
||||
current_end_date = end_date
|
||||
|
||||
while True:
|
||||
url = f"https://api.financialdatasets.ai/news/?ticker={ticker}&end_date={current_end_date}"
|
||||
if start_date:
|
||||
url += f"&start_date={start_date}"
|
||||
url += f"&limit={limit}"
|
||||
|
||||
response = _make_api_request(url, headers)
|
||||
if response.status_code != 200:
|
||||
raise ValueError(
|
||||
f"Error fetching data: {ticker} - {response.status_code} - {response.text}",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
response_model = CompanyNewsResponse(**data)
|
||||
company_news = response_model.news
|
||||
|
||||
if not company_news:
|
||||
break
|
||||
|
||||
all_news.extend(company_news)
|
||||
|
||||
if not start_date or len(company_news) < limit:
|
||||
break
|
||||
|
||||
current_end_date = min(
|
||||
news.date for news in company_news if news.date is not None
|
||||
).split("T")[0]
|
||||
|
||||
if current_end_date <= start_date:
|
||||
break
|
||||
|
||||
return all_news
|
||||
|
||||
|
||||
def get_company_news(
|
||||
ticker: str,
|
||||
end_date: str,
|
||||
@@ -631,102 +218,49 @@ def get_company_news(
|
||||
limit: int = 1000,
|
||||
) -> list[CompanyNews]:
|
||||
"""Fetch company news from cache or API."""
|
||||
config = get_config()
|
||||
data_source = config.source
|
||||
api_key = config.api_key
|
||||
ticker = normalize_symbol(ticker)
|
||||
for source in _router.api_sources():
|
||||
cache_key = (
|
||||
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{source}"
|
||||
)
|
||||
if cached_data := _cache.get_company_news(cache_key):
|
||||
return [CompanyNews(**news) for news in cached_data]
|
||||
|
||||
cache_key = (
|
||||
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{data_source}"
|
||||
all_news, data_source = _router.get_company_news(
|
||||
ticker=ticker,
|
||||
end_date=end_date,
|
||||
start_date=start_date,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if cached_data := _cache.get_company_news(cache_key):
|
||||
return [CompanyNews(**news) for news in cached_data]
|
||||
|
||||
if data_source == "finnhub":
|
||||
all_news = _fetch_finnhub_company_news(
|
||||
ticker,
|
||||
start_date,
|
||||
end_date,
|
||||
limit,
|
||||
api_key,
|
||||
)
|
||||
else:
|
||||
all_news = _fetch_fd_company_news(
|
||||
ticker,
|
||||
start_date,
|
||||
end_date,
|
||||
limit,
|
||||
api_key,
|
||||
)
|
||||
|
||||
if not all_news:
|
||||
return []
|
||||
|
||||
_cache.set_company_news(
|
||||
cache_key,
|
||||
[news.model_dump() for news in all_news],
|
||||
)
|
||||
cache_key = f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{data_source}"
|
||||
_cache.set_company_news(cache_key, [news.model_dump() for news in all_news])
|
||||
return all_news
|
||||
|
||||
|
||||
def _convert_finnhub_insider_trade(ticker: str, trade: dict) -> InsiderTrade:
|
||||
"""Convert Finnhub insider trade format to InsiderTrade model."""
|
||||
shares_after = trade.get("share", 0)
|
||||
change = trade.get("change", 0)
|
||||
|
||||
return InsiderTrade(
|
||||
ticker=ticker,
|
||||
issuer=None,
|
||||
name=trade.get("name", ""),
|
||||
title=None,
|
||||
is_board_director=None,
|
||||
transaction_date=trade.get("transactionDate", ""),
|
||||
transaction_shares=abs(change),
|
||||
transaction_price_per_share=trade.get("transactionPrice", 0.0),
|
||||
transaction_value=abs(change) * trade.get("transactionPrice", 0.0),
|
||||
shares_owned_before_transaction=(
|
||||
shares_after - change if shares_after and change else None
|
||||
),
|
||||
shares_owned_after_transaction=float(shares_after)
|
||||
if shares_after
|
||||
else None,
|
||||
security_title=None,
|
||||
filing_date=trade.get("filingDate", ""),
|
||||
)
|
||||
|
||||
|
||||
def get_market_cap(ticker: str, end_date: str) -> float | None:
|
||||
"""Fetch market cap from the API. Finnhub values are converted from millions."""
|
||||
config = get_config()
|
||||
data_source = config.source
|
||||
api_key = config.api_key
|
||||
ticker = normalize_symbol(ticker)
|
||||
|
||||
# For today's date, use company facts API
|
||||
if end_date == datetime.datetime.now().strftime("%Y-%m-%d"):
|
||||
headers = {"X-API-KEY": api_key}
|
||||
url = (
|
||||
f"https://api.financialdatasets.ai/company/facts/?ticker={ticker}"
|
||||
def _metrics_lookup(symbol: str, date: str):
|
||||
for source in _router.api_sources():
|
||||
cache_key = f"{symbol}_ttm_{date}_10_{source}"
|
||||
if cached_data := _cache.get_financial_metrics(cache_key):
|
||||
return [FinancialMetrics(**metric) for metric in cached_data], source
|
||||
return _router.get_financial_metrics(
|
||||
ticker=symbol,
|
||||
end_date=date,
|
||||
period="ttm",
|
||||
limit=10,
|
||||
)
|
||||
response = _make_api_request(url, headers)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
response_model = CompanyFactsResponse(**data)
|
||||
return response_model.company_facts.market_cap
|
||||
|
||||
financial_metrics = get_financial_metrics(ticker, end_date)
|
||||
if not financial_metrics:
|
||||
return None
|
||||
|
||||
market_cap = financial_metrics[0].market_cap
|
||||
if not market_cap:
|
||||
return None
|
||||
|
||||
# Finnhub returns market cap in millions
|
||||
if data_source == "finnhub":
|
||||
market_cap = market_cap * 1_000_000
|
||||
|
||||
market_cap, _ = _router.get_market_cap(
|
||||
ticker=ticker,
|
||||
end_date=end_date,
|
||||
metrics_lookup=_metrics_lookup,
|
||||
)
|
||||
return market_cap
|
||||
|
||||
|
||||
|
||||
193
backend/tools/technical_signals.py
Normal file
193
backend/tools/technical_signals.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Structured technical signal analysis used by technical tools."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class TechnicalSignal:
|
||||
"""Structured technical analysis result for one ticker."""
|
||||
|
||||
ticker: str
|
||||
current_price: float = 0.0
|
||||
ma5: float = 0.0
|
||||
ma10: float = 0.0
|
||||
ma20: float = 0.0
|
||||
ma50: float = 0.0
|
||||
ma200: Optional[float] = None
|
||||
bias_ma5_pct: float = 0.0
|
||||
momentum_5d_pct: float = 0.0
|
||||
momentum_10d_pct: float = 0.0
|
||||
momentum_20d_pct: float = 0.0
|
||||
annualized_volatility_pct: float = 0.0
|
||||
rsi14: float = 50.0
|
||||
macd: float = 0.0
|
||||
macd_signal: float = 0.0
|
||||
bollinger_upper: float = 0.0
|
||||
bollinger_mid: float = 0.0
|
||||
bollinger_lower: float = 0.0
|
||||
trend: str = "NEUTRAL"
|
||||
mean_reversion_signal: str = "NEUTRAL"
|
||||
risk_level: str = "MODERATE RISK"
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
def to_summary(self) -> Dict[str, object]:
|
||||
"""Compact dict for logs/tests."""
|
||||
return {
|
||||
"ticker": self.ticker,
|
||||
"trend": self.trend,
|
||||
"mean_reversion_signal": self.mean_reversion_signal,
|
||||
"risk_level": self.risk_level,
|
||||
"current_price": self.current_price,
|
||||
"rsi14": self.rsi14,
|
||||
"annualized_volatility_pct": self.annualized_volatility_pct,
|
||||
}
|
||||
|
||||
|
||||
class StockTechnicalAnalyzer:
|
||||
"""Lightweight technical analyzer adapted for EvoTraders tools."""
|
||||
|
||||
def analyze(self, ticker: str, df: pd.DataFrame) -> TechnicalSignal:
|
||||
"""Analyze one ticker from OHLC price history."""
|
||||
result = TechnicalSignal(ticker=ticker)
|
||||
if df is None or df.empty or len(df) < 5:
|
||||
result.notes.append("Insufficient price data")
|
||||
return result
|
||||
|
||||
frame = df.sort_values("time").reset_index(drop=True).copy()
|
||||
frame["close"] = pd.to_numeric(frame["close"], errors="coerce")
|
||||
frame["returns"] = frame["close"].pct_change()
|
||||
|
||||
for window in (5, 10, 20, 50, 200):
|
||||
frame[f"MA_{window}"] = frame["close"].rolling(window).mean()
|
||||
|
||||
frame["EMA_12"] = frame["close"].ewm(span=12, adjust=False).mean()
|
||||
frame["EMA_26"] = frame["close"].ewm(span=26, adjust=False).mean()
|
||||
frame["MACD"] = frame["EMA_12"] - frame["EMA_26"]
|
||||
frame["MACD_SIGNAL"] = (
|
||||
frame["MACD"].ewm(span=9, adjust=False).mean()
|
||||
)
|
||||
|
||||
delta = frame["close"].diff()
|
||||
gain = delta.where(delta > 0, 0.0)
|
||||
loss = -delta.where(delta < 0, 0.0)
|
||||
avg_gain = gain.rolling(14).mean()
|
||||
avg_loss = loss.rolling(14).mean()
|
||||
rs = avg_gain / avg_loss.replace(0, pd.NA)
|
||||
frame["RSI_14"] = 100 - (100 / (1 + rs))
|
||||
|
||||
frame["BB_MID"] = frame["close"].rolling(20).mean()
|
||||
frame["BB_STD"] = frame["close"].rolling(20).std()
|
||||
frame["BB_UPPER"] = frame["BB_MID"] + 2 * frame["BB_STD"]
|
||||
frame["BB_LOWER"] = frame["BB_MID"] - 2 * frame["BB_STD"]
|
||||
|
||||
latest = frame.iloc[-1]
|
||||
result.current_price = _safe_number(latest["close"])
|
||||
result.ma5 = _safe_number(latest["MA_5"])
|
||||
result.ma10 = _safe_number(latest["MA_10"])
|
||||
result.ma20 = _safe_number(latest["MA_20"])
|
||||
result.ma50 = _safe_number(latest["MA_50"])
|
||||
result.ma200 = _safe_optional(latest["MA_200"])
|
||||
result.bias_ma5_pct = _percent_gap(result.current_price, result.ma5)
|
||||
result.momentum_5d_pct = _lookback_return(frame["close"], 5)
|
||||
result.momentum_10d_pct = _lookback_return(frame["close"], 10)
|
||||
result.momentum_20d_pct = _lookback_return(frame["close"], 20)
|
||||
result.annualized_volatility_pct = _safe_number(
|
||||
frame["returns"].tail(20).std() * (252**0.5) * 100,
|
||||
)
|
||||
result.rsi14 = _safe_number(latest["RSI_14"], default=50.0)
|
||||
result.macd = _safe_number(latest["MACD"])
|
||||
result.macd_signal = _safe_number(latest["MACD_SIGNAL"])
|
||||
result.bollinger_mid = _safe_number(latest["BB_MID"])
|
||||
result.bollinger_upper = _safe_number(latest["BB_UPPER"])
|
||||
result.bollinger_lower = _safe_number(latest["BB_LOWER"])
|
||||
result.trend = _classify_trend(result)
|
||||
result.mean_reversion_signal = _classify_mean_reversion(result)
|
||||
result.risk_level = _classify_risk(result.annualized_volatility_pct)
|
||||
result.notes = _build_notes(result)
|
||||
return result
|
||||
|
||||
|
||||
def _safe_number(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_optional(value) -> Optional[float]:
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _lookback_return(series: pd.Series, lookback: int) -> float:
|
||||
if len(series) <= lookback:
|
||||
return 0.0
|
||||
base = _safe_number(series.iloc[-lookback - 1])
|
||||
latest = _safe_number(series.iloc[-1])
|
||||
if base <= 0:
|
||||
return 0.0
|
||||
return ((latest / base) - 1) * 100
|
||||
|
||||
|
||||
def _percent_gap(value: float, anchor: float) -> float:
|
||||
if anchor <= 0:
|
||||
return 0.0
|
||||
return ((value - anchor) / anchor) * 100
|
||||
|
||||
|
||||
def _classify_trend(result: TechnicalSignal) -> str:
|
||||
bullish_stack = (
|
||||
result.current_price >= result.ma5 >= result.ma10 >= result.ma20 > 0
|
||||
)
|
||||
if bullish_stack and result.macd >= result.macd_signal:
|
||||
return "STRONG BULLISH"
|
||||
if bullish_stack:
|
||||
return "BULLISH"
|
||||
if result.current_price < result.ma20 and result.macd < result.macd_signal:
|
||||
return "BEARISH"
|
||||
return "NEUTRAL"
|
||||
|
||||
|
||||
def _classify_mean_reversion(result: TechnicalSignal) -> str:
|
||||
if result.rsi14 <= 30 or (
|
||||
result.bollinger_lower > 0
|
||||
and result.current_price <= result.bollinger_lower
|
||||
):
|
||||
return "OVERSOLD"
|
||||
if result.rsi14 >= 70 or (
|
||||
result.bollinger_upper > 0
|
||||
and result.current_price >= result.bollinger_upper
|
||||
):
|
||||
return "OVERBOUGHT"
|
||||
return "NEUTRAL"
|
||||
|
||||
|
||||
def _classify_risk(volatility_pct: float) -> str:
|
||||
if volatility_pct > 50:
|
||||
return "HIGH RISK"
|
||||
if volatility_pct > 25:
|
||||
return "MODERATE RISK"
|
||||
return "LOW RISK"
|
||||
|
||||
|
||||
def _build_notes(result: TechnicalSignal) -> List[str]:
|
||||
notes = []
|
||||
if abs(result.bias_ma5_pct) > 5:
|
||||
notes.append("Price extended from MA5")
|
||||
if result.macd > result.macd_signal:
|
||||
notes.append("MACD supports upside momentum")
|
||||
if result.mean_reversion_signal == "OVERSOLD":
|
||||
notes.append("Potential rebound setup")
|
||||
if result.mean_reversion_signal == "OVERBOUGHT":
|
||||
notes.append("Potential pullback setup")
|
||||
return notes
|
||||
Reference in New Issue
Block a user