major update
Some checks failed
Deploy / lint (push) Failing after 8s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-27 16:08:09 +01:00
parent 61ab24490d
commit 181cfe6588
71 changed files with 7647 additions and 281 deletions

View File

@@ -6,6 +6,7 @@ and marks the fundamental dimension score as stale on new data.
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
@@ -37,6 +38,7 @@ async def store_fundamental(
revenue_growth: float | None = None,
earnings_surprise: float | None = None,
market_cap: float | None = None,
unavailable_fields: dict[str, str] | None = None,
) -> FundamentalData:
"""Store or update fundamental data for a ticker.
@@ -52,6 +54,7 @@ async def store_fundamental(
existing = result.scalar_one_or_none()
now = datetime.now(timezone.utc)
unavailable_fields_json = json.dumps(unavailable_fields or {})
if existing is not None:
existing.pe_ratio = pe_ratio
@@ -59,6 +62,7 @@ async def store_fundamental(
existing.earnings_surprise = earnings_surprise
existing.market_cap = market_cap
existing.fetched_at = now
existing.unavailable_fields_json = unavailable_fields_json
record = existing
else:
record = FundamentalData(
@@ -68,6 +72,7 @@ async def store_fundamental(
earnings_surprise=earnings_surprise,
market_cap=market_cap,
fetched_at=now,
unavailable_fields_json=unavailable_fields_json,
)
db.add(record)

View File

@@ -34,10 +34,32 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
return ticker
def _compute_quality_score(
rr: float,
strength: int,
distance: float,
entry_price: float,
*,
w_rr: float = 0.35,
w_strength: float = 0.35,
w_proximity: float = 0.30,
rr_cap: float = 10.0,
) -> float:
"""Compute a quality score for a candidate S/R level.
Combines normalized R:R ratio, level strength, and proximity to entry
into a single 01 score using configurable weights.
"""
norm_rr = min(rr / rr_cap, 1.0)
norm_strength = strength / 100.0
norm_proximity = 1.0 - min(distance / entry_price, 1.0)
return w_rr * norm_rr + w_strength * norm_strength + w_proximity * norm_proximity
async def scan_ticker(
db: AsyncSession,
symbol: str,
rr_threshold: float = 3.0,
rr_threshold: float = 1.5,
atr_multiplier: float = 1.5,
) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold.
@@ -120,41 +142,65 @@ async def scan_ticker(
setups: list[TradeSetup] = []
# Long setup: target = nearest SR above, stop = entry - ATR × multiplier
# Check all resistance levels above and pick the one with the best quality score
if levels_above:
target = levels_above[0].price_level
stop = entry_price - (atr_value * atr_multiplier)
reward = target - entry_price
risk = entry_price - stop
if risk > 0 and reward > 0:
rr = reward / risk
if rr >= rr_threshold:
if risk > 0:
best_quality = 0.0
best_candidate_rr = 0.0
best_candidate_target = 0.0
for lv in levels_above:
reward = lv.price_level - entry_price
if reward > 0:
rr = reward / risk
if rr >= rr_threshold:
distance = lv.price_level - entry_price
quality = _compute_quality_score(rr, lv.strength, distance, entry_price)
if quality > best_quality:
best_quality = quality
best_candidate_rr = rr
best_candidate_target = lv.price_level
if best_candidate_rr > 0:
setups.append(TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=round(entry_price, 4),
stop_loss=round(stop, 4),
target=round(target, 4),
rr_ratio=round(rr, 4),
target=round(best_candidate_target, 4),
rr_ratio=round(best_candidate_rr, 4),
composite_score=round(composite_score, 4),
detected_at=now,
))
# Short setup: target = nearest SR below, stop = entry + ATR × multiplier
# Check all support levels below and pick the one with the best quality score
if levels_below:
target = levels_below[0].price_level
stop = entry_price + (atr_value * atr_multiplier)
reward = entry_price - target
risk = stop - entry_price
if risk > 0 and reward > 0:
rr = reward / risk
if rr >= rr_threshold:
if risk > 0:
best_quality = 0.0
best_candidate_rr = 0.0
best_candidate_target = 0.0
for lv in levels_below:
reward = entry_price - lv.price_level
if reward > 0:
rr = reward / risk
if rr >= rr_threshold:
distance = entry_price - lv.price_level
quality = _compute_quality_score(rr, lv.strength, distance, entry_price)
if quality > best_quality:
best_quality = quality
best_candidate_rr = rr
best_candidate_target = lv.price_level
if best_candidate_rr > 0:
setups.append(TradeSetup(
ticker_id=ticker.id,
direction="short",
entry_price=round(entry_price, 4),
stop_loss=round(stop, 4),
target=round(target, 4),
rr_ratio=round(rr, 4),
target=round(best_candidate_target, 4),
rr_ratio=round(best_candidate_rr, 4),
composite_score=round(composite_score, 4),
detected_at=now,
))
@@ -177,7 +223,7 @@ async def scan_ticker(
async def scan_all_tickers(
db: AsyncSession,
rr_threshold: float = 3.0,
rr_threshold: float = 1.5,
atr_multiplier: float = 1.5,
) -> list[TradeSetup]:
"""Scan all tracked tickers for trade setups.

View File

@@ -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.

View File

@@ -6,6 +6,7 @@ using a time-decay weighted average over a configurable lookback window.
from __future__ import annotations
import json
import math
from datetime import datetime, timedelta, timezone
@@ -34,6 +35,8 @@ async def store_sentiment(
confidence: int,
source: str,
timestamp: datetime | None = None,
reasoning: str = "",
citations: list[dict] | None = None,
) -> SentimentScore:
"""Store a new sentiment record for a ticker."""
ticker = await _get_ticker(db, symbol)
@@ -41,12 +44,17 @@ async def store_sentiment(
if timestamp is None:
timestamp = datetime.now(timezone.utc)
if citations is None:
citations = []
record = SentimentScore(
ticker_id=ticker.id,
classification=classification,
confidence=confidence,
source=source,
timestamp=timestamp,
reasoning=reasoning,
citations_json=json.dumps(citations),
)
db.add(record)
await db.commit()

View File

@@ -204,6 +204,121 @@ def detect_sr_levels(
return tagged
def cluster_sr_zones(
levels: list[dict],
current_price: float,
tolerance: float = 0.02,
max_zones: int | None = None,
) -> list[dict]:
"""Cluster nearby S/R levels into zones.
Returns list of zone dicts:
{
"low": float,
"high": float,
"midpoint": float,
"strength": int, # sum of constituent strengths, capped at 100
"type": "support" | "resistance",
"level_count": int,
}
"""
if not levels:
return []
if max_zones is not None and max_zones <= 0:
return []
# 1. Sort levels by price_level ascending
sorted_levels = sorted(levels, key=lambda x: x["price_level"])
# 2. Greedy merge into clusters
clusters: list[list[dict]] = []
current_cluster: list[dict] = [sorted_levels[0]]
for level in sorted_levels[1:]:
# Compute current cluster midpoint
prices = [l["price_level"] for l in current_cluster]
cluster_low = min(prices)
cluster_high = max(prices)
cluster_mid = (cluster_low + cluster_high) / 2.0
# Check if within tolerance of cluster midpoint
if cluster_mid != 0:
distance_pct = abs(level["price_level"] - cluster_mid) / cluster_mid
else:
distance_pct = abs(level["price_level"])
if distance_pct <= tolerance:
current_cluster.append(level)
else:
clusters.append(current_cluster)
current_cluster = [level]
clusters.append(current_cluster)
# 3. Compute zone for each cluster
zones: list[dict] = []
for cluster in clusters:
prices = [l["price_level"] for l in cluster]
low = min(prices)
high = max(prices)
midpoint = (low + high) / 2.0
strength = min(100, sum(l["strength"] for l in cluster))
level_count = len(cluster)
# 4. Tag zone type
zone_type = "support" if midpoint < current_price else "resistance"
zones.append({
"low": low,
"high": high,
"midpoint": midpoint,
"strength": strength,
"type": zone_type,
"level_count": level_count,
})
# 5. Split into support and resistance pools, each sorted by strength desc
support_zones = sorted(
[z for z in zones if z["type"] == "support"],
key=lambda z: z["strength"],
reverse=True,
)
resistance_zones = sorted(
[z for z in zones if z["type"] == "resistance"],
key=lambda z: z["strength"],
reverse=True,
)
# 6. Interleave pick: alternate strongest from each pool
selected: list[dict] = []
limit = max_zones if max_zones is not None else len(zones)
si, ri = 0, 0
pick_support = True # start with support pool
while len(selected) < limit and (si < len(support_zones) or ri < len(resistance_zones)):
if pick_support:
if si < len(support_zones):
selected.append(support_zones[si])
si += 1
elif ri < len(resistance_zones):
selected.append(resistance_zones[ri])
ri += 1
else:
if ri < len(resistance_zones):
selected.append(resistance_zones[ri])
ri += 1
elif si < len(support_zones):
selected.append(support_zones[si])
si += 1
pick_support = not pick_support
# 7. Sort final selection by strength descending
selected.sort(key=lambda z: z["strength"], reverse=True)
return selected
async def recalculate_sr_levels(
db: AsyncSession,