major update
This commit is contained in:
@@ -85,8 +85,14 @@ async def _save_weights(db: AsyncSession, weights: dict[str, float]) -> None:
|
||||
# Dimension score computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _compute_technical_score(db: AsyncSession, symbol: str) -> float | None:
|
||||
"""Compute technical dimension score from ADX, EMA, RSI."""
|
||||
async def _compute_technical_score(
|
||||
db: AsyncSession, symbol: str
|
||||
) -> tuple[float | None, dict | None]:
|
||||
"""Compute technical dimension score from ADX, EMA, RSI.
|
||||
|
||||
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
|
||||
TypedDict shape: {sub_scores, formula, unavailable}.
|
||||
"""
|
||||
from app.services.indicator_service import (
|
||||
compute_adx,
|
||||
compute_ema,
|
||||
@@ -97,147 +103,366 @@ async def _compute_technical_score(db: AsyncSession, symbol: str) -> float | Non
|
||||
|
||||
records = await query_ohlcv(db, symbol)
|
||||
if not records:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
_, highs, lows, closes, _ = _extract_ohlcv(records)
|
||||
|
||||
scores: list[tuple[float, float]] = [] # (weight, score)
|
||||
sub_scores: list[dict] = []
|
||||
unavailable: list[dict[str, str]] = []
|
||||
|
||||
# ADX (weight 0.4) — needs 28+ bars
|
||||
try:
|
||||
adx_result = compute_adx(highs, lows, closes)
|
||||
scores.append((0.4, adx_result["score"]))
|
||||
except Exception:
|
||||
pass
|
||||
sub_scores.append({
|
||||
"name": "ADX",
|
||||
"score": adx_result["score"],
|
||||
"weight": 0.4,
|
||||
"raw_value": adx_result["adx"],
|
||||
"description": "ADX value (0-100). Higher = stronger trend.",
|
||||
})
|
||||
except Exception as exc:
|
||||
unavailable.append({"name": "ADX", "reason": str(exc) or "Insufficient data for ADX"})
|
||||
|
||||
# EMA (weight 0.3) — needs period+1 bars
|
||||
try:
|
||||
ema_result = compute_ema(closes)
|
||||
pct_diff = (
|
||||
round(
|
||||
(ema_result["latest_close"] - ema_result["ema"])
|
||||
/ ema_result["ema"]
|
||||
* 100.0,
|
||||
4,
|
||||
)
|
||||
if ema_result["ema"] != 0
|
||||
else 0.0
|
||||
)
|
||||
scores.append((0.3, ema_result["score"]))
|
||||
except Exception:
|
||||
pass
|
||||
sub_scores.append({
|
||||
"name": "EMA",
|
||||
"score": ema_result["score"],
|
||||
"weight": 0.3,
|
||||
"raw_value": pct_diff,
|
||||
"description": f"Price {pct_diff}% {'above' if pct_diff >= 0 else 'below'} EMA(20). Score: 50 + pct_diff * 10.",
|
||||
})
|
||||
except Exception as exc:
|
||||
unavailable.append({"name": "EMA", "reason": str(exc) or "Insufficient data for EMA"})
|
||||
|
||||
# RSI (weight 0.3) — needs 15+ bars
|
||||
try:
|
||||
rsi_result = compute_rsi(closes)
|
||||
scores.append((0.3, rsi_result["score"]))
|
||||
except Exception:
|
||||
pass
|
||||
sub_scores.append({
|
||||
"name": "RSI",
|
||||
"score": rsi_result["score"],
|
||||
"weight": 0.3,
|
||||
"raw_value": rsi_result["rsi"],
|
||||
"description": "RSI(14) value. Score equals RSI.",
|
||||
})
|
||||
except Exception as exc:
|
||||
unavailable.append({"name": "RSI", "reason": str(exc) or "Insufficient data for RSI"})
|
||||
|
||||
if not scores:
|
||||
return None
|
||||
breakdown: dict = {
|
||||
"sub_scores": [],
|
||||
"formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI, re-normalized if any sub-score unavailable.",
|
||||
"unavailable": unavailable,
|
||||
}
|
||||
return None, breakdown
|
||||
|
||||
total_weight = sum(w for w, _ in scores)
|
||||
if total_weight == 0:
|
||||
return None
|
||||
return None, None
|
||||
weighted = sum(w * s for w, s in scores) / total_weight
|
||||
return max(0.0, min(100.0, weighted))
|
||||
final_score = max(0.0, min(100.0, weighted))
|
||||
|
||||
breakdown = {
|
||||
"sub_scores": sub_scores,
|
||||
"formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI, re-normalized if any sub-score unavailable.",
|
||||
"unavailable": unavailable,
|
||||
}
|
||||
|
||||
return final_score, breakdown
|
||||
|
||||
|
||||
async def _compute_sr_quality_score(db: AsyncSession, symbol: str) -> float | None:
|
||||
async def _compute_sr_quality_score(
|
||||
db: AsyncSession, symbol: str
|
||||
) -> tuple[float | None, dict | None]:
|
||||
"""Compute S/R quality dimension score.
|
||||
|
||||
Based on number of strong levels, proximity to current price, avg strength.
|
||||
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
|
||||
TypedDict shape: {sub_scores, formula, unavailable}.
|
||||
"""
|
||||
from app.services.price_service import query_ohlcv
|
||||
from app.services.sr_service import get_sr_levels
|
||||
|
||||
formula = "Sum of sub-scores: Strong Count (max 40) + Proximity (max 30) + Avg Strength (max 30), clamped to [0, 100]."
|
||||
|
||||
records = await query_ohlcv(db, symbol)
|
||||
if not records:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
current_price = float(records[-1].close)
|
||||
if current_price <= 0:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
try:
|
||||
levels = await get_sr_levels(db, symbol)
|
||||
except Exception:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
if not levels:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
sub_scores: list[dict] = []
|
||||
|
||||
# Factor 1: Number of strong levels (strength >= 50) — max 40 pts
|
||||
strong_count = sum(1 for lv in levels if lv.strength >= 50)
|
||||
count_score = min(40.0, strong_count * 10.0)
|
||||
sub_scores.append({
|
||||
"name": "Strong Count",
|
||||
"score": count_score,
|
||||
"weight": 40.0,
|
||||
"raw_value": strong_count,
|
||||
"description": f"{strong_count} strong level(s) (strength >= 50). Score: min(40, count * 10).",
|
||||
})
|
||||
|
||||
# Factor 2: Proximity of nearest level to current price — max 30 pts
|
||||
distances = [
|
||||
abs(lv.price_level - current_price) / current_price for lv in levels
|
||||
]
|
||||
nearest_dist = min(distances) if distances else 1.0
|
||||
nearest_dist_pct = round(nearest_dist * 100.0, 4)
|
||||
# Closer = higher score. 0% distance = 30, 5%+ = 0
|
||||
proximity_score = max(0.0, min(30.0, 30.0 * (1.0 - nearest_dist / 0.05)))
|
||||
sub_scores.append({
|
||||
"name": "Proximity",
|
||||
"score": proximity_score,
|
||||
"weight": 30.0,
|
||||
"raw_value": nearest_dist_pct,
|
||||
"description": f"Nearest S/R level is {nearest_dist_pct}% from price. Score: 30 * (1 - dist/5%), clamped to [0, 30].",
|
||||
})
|
||||
|
||||
# Factor 3: Average strength — max 30 pts
|
||||
avg_strength = sum(lv.strength for lv in levels) / len(levels)
|
||||
strength_score = min(30.0, avg_strength * 0.3)
|
||||
sub_scores.append({
|
||||
"name": "Avg Strength",
|
||||
"score": strength_score,
|
||||
"weight": 30.0,
|
||||
"raw_value": round(avg_strength, 4),
|
||||
"description": f"Average level strength: {round(avg_strength, 2)}. Score: min(30, avg * 0.3).",
|
||||
})
|
||||
|
||||
total = count_score + proximity_score + strength_score
|
||||
return max(0.0, min(100.0, total))
|
||||
final_score = max(0.0, min(100.0, total))
|
||||
|
||||
breakdown: dict = {
|
||||
"sub_scores": sub_scores,
|
||||
"formula": formula,
|
||||
"unavailable": [],
|
||||
}
|
||||
|
||||
return final_score, breakdown
|
||||
|
||||
|
||||
async def _compute_sentiment_score(db: AsyncSession, symbol: str) -> float | None:
|
||||
"""Compute sentiment dimension score via sentiment service."""
|
||||
from app.services.sentiment_service import compute_sentiment_dimension_score
|
||||
async def _compute_sentiment_score(
|
||||
db: AsyncSession, symbol: str
|
||||
) -> tuple[float | None, dict | None]:
|
||||
"""Compute sentiment dimension score via sentiment service.
|
||||
|
||||
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
|
||||
TypedDict shape: {sub_scores, formula, unavailable}.
|
||||
"""
|
||||
from app.services.sentiment_service import (
|
||||
compute_sentiment_dimension_score,
|
||||
get_sentiment_scores,
|
||||
)
|
||||
|
||||
lookback_hours: float = 24
|
||||
decay_rate: float = 0.1
|
||||
|
||||
try:
|
||||
return await compute_sentiment_dimension_score(db, symbol)
|
||||
scores = await get_sentiment_scores(db, symbol, lookback_hours)
|
||||
except Exception:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
if not scores:
|
||||
breakdown: dict = {
|
||||
"sub_scores": [],
|
||||
"formula": (
|
||||
f"Time-decay weighted average over {lookback_hours}h window "
|
||||
f"with decay_rate={decay_rate}: "
|
||||
"sum(base_score * exp(-decay_rate * hours_since)) / sum(exp(-decay_rate * hours_since))"
|
||||
),
|
||||
"unavailable": [
|
||||
{"name": "sentiment_records", "reason": "No sentiment records in lookback window"}
|
||||
],
|
||||
}
|
||||
return None, breakdown
|
||||
|
||||
try:
|
||||
score = await compute_sentiment_dimension_score(db, symbol, lookback_hours, decay_rate)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
sub_scores: list[dict] = [
|
||||
{
|
||||
"name": "record_count",
|
||||
"score": score if score is not None else 0.0,
|
||||
"weight": 1.0,
|
||||
"raw_value": len(scores),
|
||||
"description": f"Number of sentiment records used in the lookback window ({lookback_hours}h).",
|
||||
},
|
||||
{
|
||||
"name": "decay_rate",
|
||||
"score": score if score is not None else 0.0,
|
||||
"weight": 1.0,
|
||||
"raw_value": decay_rate,
|
||||
"description": "Exponential decay rate applied to older records (higher = faster decay).",
|
||||
},
|
||||
{
|
||||
"name": "lookback_window",
|
||||
"score": score if score is not None else 0.0,
|
||||
"weight": 1.0,
|
||||
"raw_value": lookback_hours,
|
||||
"description": f"Lookback window in hours for sentiment records ({lookback_hours}h).",
|
||||
},
|
||||
]
|
||||
|
||||
formula = (
|
||||
f"Time-decay weighted average over {lookback_hours}h window "
|
||||
f"with decay_rate={decay_rate}: "
|
||||
"sum(base_score * exp(-decay_rate * hours_since)) / sum(exp(-decay_rate * hours_since))"
|
||||
)
|
||||
|
||||
breakdown = {
|
||||
"sub_scores": sub_scores,
|
||||
"formula": formula,
|
||||
"unavailable": [],
|
||||
}
|
||||
|
||||
return score, breakdown
|
||||
|
||||
|
||||
async def _compute_fundamental_score(db: AsyncSession, symbol: str) -> float | None:
|
||||
async def _compute_fundamental_score(
|
||||
db: AsyncSession, symbol: str
|
||||
) -> tuple[float | None, dict | None]:
|
||||
"""Compute fundamental dimension score.
|
||||
|
||||
Normalized composite of P/E (lower is better), revenue growth
|
||||
(higher is better), earnings surprise (higher is better).
|
||||
|
||||
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
|
||||
TypedDict shape: {sub_scores, formula, unavailable}.
|
||||
"""
|
||||
from app.services.fundamental_service import get_fundamental
|
||||
|
||||
fund = await get_fundamental(db, symbol)
|
||||
if fund is None:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
weight = 1.0 / 3.0
|
||||
scores: list[float] = []
|
||||
sub_scores: list[dict] = []
|
||||
unavailable: list[dict[str, str]] = []
|
||||
|
||||
formula = (
|
||||
"Equal-weighted average of available sub-scores: "
|
||||
"(PE_Ratio + Revenue_Growth + Earnings_Surprise) / count. "
|
||||
"PE: 100 - (pe - 15) * (100/30), clamped [0,100]. "
|
||||
"Revenue Growth: 50 + growth% * 2.5, clamped [0,100]. "
|
||||
"Earnings Surprise: 50 + surprise% * 5.0, clamped [0,100]."
|
||||
)
|
||||
|
||||
# P/E: lower is better. 0-15 = 100, 15-30 = 50-100, 30+ = 0-50
|
||||
if fund.pe_ratio is not None and fund.pe_ratio > 0:
|
||||
pe_score = max(0.0, min(100.0, 100.0 - (fund.pe_ratio - 15.0) * (100.0 / 30.0)))
|
||||
scores.append(pe_score)
|
||||
sub_scores.append({
|
||||
"name": "PE Ratio",
|
||||
"score": pe_score,
|
||||
"weight": weight,
|
||||
"raw_value": fund.pe_ratio,
|
||||
"description": "PE ratio (lower is better). Score: 100 - (pe - 15) * (100/30), clamped [0,100].",
|
||||
})
|
||||
else:
|
||||
unavailable.append({
|
||||
"name": "PE Ratio",
|
||||
"reason": "PE ratio not available or not positive",
|
||||
})
|
||||
|
||||
# Revenue growth: higher is better. 0% = 50, 20%+ = 100, -20% = 0
|
||||
if fund.revenue_growth is not None:
|
||||
rg_score = max(0.0, min(100.0, 50.0 + fund.revenue_growth * 2.5))
|
||||
scores.append(rg_score)
|
||||
sub_scores.append({
|
||||
"name": "Revenue Growth",
|
||||
"score": rg_score,
|
||||
"weight": weight,
|
||||
"raw_value": fund.revenue_growth,
|
||||
"description": "Revenue growth %. Score: 50 + growth% * 2.5, clamped [0,100].",
|
||||
})
|
||||
else:
|
||||
unavailable.append({
|
||||
"name": "Revenue Growth",
|
||||
"reason": "Revenue growth data not available",
|
||||
})
|
||||
|
||||
# Earnings surprise: higher is better. 0% = 50, 10%+ = 100, -10% = 0
|
||||
if fund.earnings_surprise is not None:
|
||||
es_score = max(0.0, min(100.0, 50.0 + fund.earnings_surprise * 5.0))
|
||||
scores.append(es_score)
|
||||
sub_scores.append({
|
||||
"name": "Earnings Surprise",
|
||||
"score": es_score,
|
||||
"weight": weight,
|
||||
"raw_value": fund.earnings_surprise,
|
||||
"description": "Earnings surprise %. Score: 50 + surprise% * 5.0, clamped [0,100].",
|
||||
})
|
||||
else:
|
||||
unavailable.append({
|
||||
"name": "Earnings Surprise",
|
||||
"reason": "Earnings surprise data not available",
|
||||
})
|
||||
|
||||
breakdown: dict = {
|
||||
"sub_scores": sub_scores,
|
||||
"formula": formula,
|
||||
"unavailable": unavailable,
|
||||
}
|
||||
|
||||
if not scores:
|
||||
return None
|
||||
return None, breakdown
|
||||
|
||||
return sum(scores) / len(scores)
|
||||
return sum(scores) / len(scores), breakdown
|
||||
|
||||
|
||||
async def _compute_momentum_score(db: AsyncSession, symbol: str) -> float | None:
|
||||
async def _compute_momentum_score(
|
||||
db: AsyncSession, symbol: str
|
||||
) -> tuple[float | None, dict | None]:
|
||||
"""Compute momentum dimension score.
|
||||
|
||||
Rate of change of price over 5-day and 20-day lookback periods.
|
||||
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
|
||||
TypedDict shape: {sub_scores, formula, unavailable}.
|
||||
"""
|
||||
from app.services.price_service import query_ohlcv
|
||||
|
||||
formula = "Weighted average: 0.5 * ROC_5 + 0.5 * ROC_20, re-normalized if any sub-score unavailable."
|
||||
|
||||
records = await query_ohlcv(db, symbol)
|
||||
if not records or len(records) < 6:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
closes = [float(r.close) for r in records]
|
||||
latest = closes[-1]
|
||||
|
||||
scores: list[tuple[float, float]] = [] # (weight, score)
|
||||
sub_scores: list[dict] = []
|
||||
unavailable: list[dict[str, str]] = []
|
||||
|
||||
# 5-day ROC (weight 0.5)
|
||||
if len(closes) >= 6 and closes[-6] > 0:
|
||||
@@ -245,21 +470,52 @@ async def _compute_momentum_score(db: AsyncSession, symbol: str) -> float | None
|
||||
# Map: -10% → 0, 0% → 50, +10% → 100
|
||||
score_5 = max(0.0, min(100.0, 50.0 + roc_5 * 5.0))
|
||||
scores.append((0.5, score_5))
|
||||
sub_scores.append({
|
||||
"name": "5-day ROC",
|
||||
"score": score_5,
|
||||
"weight": 0.5,
|
||||
"raw_value": round(roc_5, 4),
|
||||
"description": f"5-day rate of change: {round(roc_5, 2)}%. Score: 50 + ROC * 5, clamped to [0, 100].",
|
||||
})
|
||||
else:
|
||||
unavailable.append({"name": "5-day ROC", "reason": "Need at least 6 closing prices"})
|
||||
|
||||
# 20-day ROC (weight 0.5)
|
||||
if len(closes) >= 21 and closes[-21] > 0:
|
||||
roc_20 = (latest - closes[-21]) / closes[-21] * 100.0
|
||||
score_20 = max(0.0, min(100.0, 50.0 + roc_20 * 5.0))
|
||||
scores.append((0.5, score_20))
|
||||
sub_scores.append({
|
||||
"name": "20-day ROC",
|
||||
"score": score_20,
|
||||
"weight": 0.5,
|
||||
"raw_value": round(roc_20, 4),
|
||||
"description": f"20-day rate of change: {round(roc_20, 2)}%. Score: 50 + ROC * 5, clamped to [0, 100].",
|
||||
})
|
||||
else:
|
||||
unavailable.append({"name": "20-day ROC", "reason": "Need at least 21 closing prices"})
|
||||
|
||||
if not scores:
|
||||
return None
|
||||
breakdown: dict = {
|
||||
"sub_scores": [],
|
||||
"formula": formula,
|
||||
"unavailable": unavailable,
|
||||
}
|
||||
return None, breakdown
|
||||
|
||||
total_weight = sum(w for w, _ in scores)
|
||||
if total_weight == 0:
|
||||
return None
|
||||
return None, None
|
||||
weighted = sum(w * s for w, s in scores) / total_weight
|
||||
return max(0.0, min(100.0, weighted))
|
||||
final_score = max(0.0, min(100.0, weighted))
|
||||
|
||||
breakdown = {
|
||||
"sub_scores": sub_scores,
|
||||
"formula": formula,
|
||||
"unavailable": unavailable,
|
||||
}
|
||||
|
||||
return final_score, breakdown
|
||||
|
||||
|
||||
_DIMENSION_COMPUTERS = {
|
||||
@@ -289,7 +545,13 @@ async def compute_dimension_score(
|
||||
)
|
||||
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
score_val = await _DIMENSION_COMPUTERS[dimension](db, symbol)
|
||||
raw_result = await _DIMENSION_COMPUTERS[dimension](db, symbol)
|
||||
|
||||
# Handle both tuple (score, breakdown) and plain float | None returns
|
||||
if isinstance(raw_result, tuple):
|
||||
score_val = raw_result[0]
|
||||
else:
|
||||
score_val = raw_result
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -406,13 +668,15 @@ async def compute_composite_score(
|
||||
return composite, missing
|
||||
|
||||
|
||||
|
||||
async def get_score(
|
||||
db: AsyncSession, symbol: str
|
||||
) -> dict:
|
||||
"""Get composite + all dimension scores for a ticker.
|
||||
|
||||
Recomputes stale dimensions on demand, then recomputes composite.
|
||||
Returns a dict suitable for ScoreResponse.
|
||||
Returns a dict suitable for ScoreResponse, including dimension breakdowns
|
||||
and composite breakdown with re-normalization info.
|
||||
"""
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
weights = await _get_weights(db)
|
||||
@@ -450,19 +714,64 @@ async def get_score(
|
||||
)
|
||||
comp = comp_result.scalar_one_or_none()
|
||||
|
||||
# Compute breakdowns for each dimension by calling the dimension computers
|
||||
breakdowns: dict[str, dict | None] = {}
|
||||
for dim in DIMENSIONS:
|
||||
try:
|
||||
raw_result = await _DIMENSION_COMPUTERS[dim](db, symbol)
|
||||
if isinstance(raw_result, tuple) and len(raw_result) == 2:
|
||||
breakdowns[dim] = raw_result[1]
|
||||
else:
|
||||
breakdowns[dim] = None
|
||||
except Exception:
|
||||
breakdowns[dim] = None
|
||||
|
||||
# Build dimension entries with breakdowns
|
||||
dimensions = []
|
||||
missing = []
|
||||
available_dims: list[str] = []
|
||||
for dim in DIMENSIONS:
|
||||
found = next((ds for ds in dim_scores_list if ds.dimension == dim), None)
|
||||
if found is not None:
|
||||
if found is not None and not found.is_stale and found.score is not None:
|
||||
dimensions.append({
|
||||
"dimension": found.dimension,
|
||||
"score": found.score,
|
||||
"is_stale": found.is_stale,
|
||||
"computed_at": found.computed_at,
|
||||
"breakdown": breakdowns.get(dim),
|
||||
})
|
||||
w = weights.get(dim, 0.0)
|
||||
if w > 0:
|
||||
available_dims.append(dim)
|
||||
else:
|
||||
missing.append(dim)
|
||||
# Still include stale dimensions in the list if they exist in DB
|
||||
if found is not None:
|
||||
dimensions.append({
|
||||
"dimension": found.dimension,
|
||||
"score": found.score,
|
||||
"is_stale": found.is_stale,
|
||||
"computed_at": found.computed_at,
|
||||
"breakdown": breakdowns.get(dim),
|
||||
})
|
||||
|
||||
# Build composite breakdown with re-normalization info
|
||||
composite_breakdown = None
|
||||
available_weight_sum = sum(weights.get(d, 0.0) for d in available_dims)
|
||||
if available_weight_sum > 0:
|
||||
renormalized_weights = {
|
||||
d: weights.get(d, 0.0) / available_weight_sum for d in available_dims
|
||||
}
|
||||
else:
|
||||
renormalized_weights = {}
|
||||
|
||||
composite_breakdown = {
|
||||
"weights": weights,
|
||||
"available_dimensions": available_dims,
|
||||
"missing_dimensions": missing,
|
||||
"renormalized_weights": renormalized_weights,
|
||||
"formula": "Weighted average of available dimensions with re-normalized weights: sum(weight_i * score_i) / sum(weight_i)",
|
||||
}
|
||||
|
||||
return {
|
||||
"symbol": ticker.symbol,
|
||||
@@ -472,9 +781,11 @@ async def get_score(
|
||||
"dimensions": dimensions,
|
||||
"missing_dimensions": missing,
|
||||
"computed_at": comp.computed_at if comp else None,
|
||||
"composite_breakdown": composite_breakdown,
|
||||
}
|
||||
|
||||
|
||||
|
||||
async def get_rankings(db: AsyncSession) -> dict:
|
||||
"""Get all tickers ranked by composite score descending.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user