289 lines
11 KiB
Python
289 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from statistics import fmean, median
|
|
from typing import Any
|
|
|
|
from backend.features.screener.catalog import factor_catalog
|
|
from backend.features.screener.factor_math import mean, number, pearson, percentile_map, rounded
|
|
|
|
|
|
def finalize_factor_rows(
|
|
rows: list[dict[str, Any]], dataset_ready: dict[str, bool]
|
|
) -> list[dict[str, Any]]:
|
|
market_returns = [number(row.get("return_5d")) for row in rows]
|
|
valid_market = [value for value in market_returns if value is not None]
|
|
market_mean = fmean(valid_market) if valid_market else None
|
|
for row in rows:
|
|
stock_return = number(row.get("return_5d"))
|
|
row["relative_strength"] = (
|
|
rounded(stock_return - market_mean, 2)
|
|
if stock_return is not None and market_mean is not None
|
|
else None
|
|
)
|
|
_rank(rows, "return_5d", "return_5d_rank", "desc")
|
|
_rank(rows, "momentum_60_5", "momentum_60_5_rank", "desc")
|
|
_sector_factors(rows, dataset_ready)
|
|
_composite_factors(rows)
|
|
_style_factors(rows)
|
|
_market_height(rows)
|
|
known = factor_catalog()["factors"]
|
|
for row in rows:
|
|
for field in known:
|
|
row.setdefault(field, None)
|
|
return rows
|
|
|
|
|
|
def _sector_factors(rows: list[dict[str, Any]], dataset_ready: dict[str, bool]) -> None:
|
|
fields = (
|
|
"sector_strength",
|
|
"sector_return_5d",
|
|
"sector_return_20d",
|
|
"sector_momentum_rank",
|
|
"sector_stock_momentum_rank",
|
|
"sector_net_flow_5d_million",
|
|
"sector_flow_rank",
|
|
"sector_prosperity_rank",
|
|
"sector_trend_rank",
|
|
"sector_crowding_rank",
|
|
"sector_composite_score",
|
|
"sector_limit_count",
|
|
"sector_up_count",
|
|
"sector_breadth_ma20",
|
|
)
|
|
if not dataset_ready.get("industry"):
|
|
for row in rows:
|
|
row.update(dict.fromkeys(fields))
|
|
return
|
|
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for row in rows:
|
|
if row.get("sector"):
|
|
sectors[str(row["sector"])].append(row)
|
|
market_amount = sum(float(number(row.get("amount_billion")) or 0) for row in rows)
|
|
metrics = []
|
|
for name, members in sectors.items():
|
|
returns_5 = [number(row.get("return_5d")) for row in members]
|
|
returns_20 = [number(row.get("return_20d")) for row in members]
|
|
average_5 = mean(returns_5)
|
|
average_20 = mean(returns_20)
|
|
flows = [number(row.get("net_flow_5d_million")) for row in members]
|
|
sector_flow = (
|
|
sum(float(value) for value in flows)
|
|
if all(value is not None for value in flows)
|
|
else None
|
|
)
|
|
limit_values = [row.get("is_limit_up_today") for row in members]
|
|
limit_count = (
|
|
sum(bool(value) for value in limit_values)
|
|
if all(value is not None for value in limit_values)
|
|
else None
|
|
)
|
|
changes = [number(row.get("pct_chg")) for row in members]
|
|
up_count = (
|
|
sum(float(value) >= 5 for value in changes if value is not None)
|
|
if all(value is not None for value in changes)
|
|
else None
|
|
)
|
|
above = [row.get("above_ma20") for row in members]
|
|
breadth = (
|
|
sum(bool(value) for value in above) / len(above) * 100
|
|
if above and all(value is not None for value in above)
|
|
else None
|
|
)
|
|
growth = [
|
|
mean([number(row.get("revenue_yoy")), number(row.get("netprofit_yoy"))])
|
|
for row in members
|
|
]
|
|
valid_growth = [value for value in growth if value is not None]
|
|
prosperity = median(valid_growth) if valid_growth else None
|
|
turnovers = [number(row.get("turnover_rate")) for row in members]
|
|
average_turnover = mean(turnovers)
|
|
amount_share = (
|
|
sum(float(number(row.get("amount_billion")) or 0) for row in members)
|
|
/ market_amount
|
|
* 100
|
|
if market_amount
|
|
else None
|
|
)
|
|
crowding = (
|
|
average_turnover + amount_share
|
|
if average_turnover is not None and amount_share is not None
|
|
else None
|
|
)
|
|
trend = (
|
|
average_20 + breadth / 10 if average_20 is not None and breadth is not None else None
|
|
)
|
|
strength = (
|
|
min(
|
|
100,
|
|
max(
|
|
0,
|
|
50 + average_5 * 4 + (limit_count or 0) * 3 + (up_count or 0) * 0.6,
|
|
),
|
|
)
|
|
if average_5 is not None
|
|
else None
|
|
)
|
|
metrics.append(
|
|
{
|
|
"identifier": name,
|
|
"sector": name,
|
|
"return_20": average_20,
|
|
"flow": sector_flow,
|
|
"prosperity": prosperity,
|
|
"trend": trend,
|
|
"crowding": crowding,
|
|
}
|
|
)
|
|
stock_ranks = percentile_map(members, "return_20d", "desc")
|
|
for row in members:
|
|
row.update(
|
|
{
|
|
"sector_strength": rounded(strength, 1),
|
|
"sector_return_5d": rounded(average_5, 2),
|
|
"sector_return_20d": rounded(average_20, 2),
|
|
"sector_stock_momentum_rank": rounded(
|
|
stock_ranks.get(str(row["identifier"])), 4
|
|
),
|
|
"sector_net_flow_5d_million": rounded(sector_flow, 2),
|
|
"sector_limit_count": limit_count,
|
|
"sector_up_count": up_count,
|
|
"sector_breadth_ma20": rounded(breadth, 1),
|
|
}
|
|
)
|
|
rank_specs = {
|
|
"sector_momentum_rank": ("return_20", "desc"),
|
|
"sector_flow_rank": ("flow", "desc"),
|
|
"sector_prosperity_rank": ("prosperity", "desc"),
|
|
"sector_trend_rank": ("trend", "desc"),
|
|
"sector_crowding_rank": ("crowding", "desc"),
|
|
}
|
|
maps = {
|
|
output: percentile_map(metrics, source, direction)
|
|
for output, (source, direction) in rank_specs.items()
|
|
}
|
|
for name, members in sectors.items():
|
|
values = {field: mapping.get(name) for field, mapping in maps.items()}
|
|
composite = (
|
|
values["sector_prosperity_rank"] * 0.4
|
|
+ values["sector_trend_rank"] * 0.3
|
|
+ (1 - values["sector_crowding_rank"]) * 0.3
|
|
if all(value is not None for value in values.values())
|
|
else None
|
|
)
|
|
for row in members:
|
|
row.update({field: rounded(value, 4) for field, value in values.items()})
|
|
row["sector_composite_score"] = rounded(composite, 4)
|
|
for row in rows:
|
|
if not row.get("sector"):
|
|
row.update(dict.fromkeys(fields))
|
|
|
|
|
|
def _composite_factors(rows: list[dict[str, Any]]) -> None:
|
|
specs = {
|
|
"factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")),
|
|
"factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")),
|
|
"factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")),
|
|
"factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")),
|
|
"factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")),
|
|
}
|
|
for output, factor_specs in specs.items():
|
|
maps = [percentile_map(rows, field, direction) for field, direction in factor_specs]
|
|
for row in rows:
|
|
values = [mapping.get(str(row["identifier"])) for mapping in maps]
|
|
row[output] = rounded(mean(values), 4)
|
|
future_rank = percentile_map(rows, "return_20d", "desc")
|
|
weights = {}
|
|
for output in specs:
|
|
pairs = [(number(row.get(output)), future_rank.get(str(row["identifier"]))) for row in rows]
|
|
valid = [(left, right) for left, right in pairs if left is not None and right is not None]
|
|
correlation = pearson(
|
|
[float(left) for left, _ in valid],
|
|
[float(right) for _, right in valid],
|
|
)
|
|
weights[output] = max(0.05, correlation)
|
|
for row in rows:
|
|
available = [
|
|
(number(row.get(field)), weight)
|
|
for field, weight in weights.items()
|
|
if number(row.get(field)) is not None
|
|
]
|
|
row["multi_factor_composite"] = (
|
|
rounded(
|
|
sum(float(value) * weight for value, weight in available)
|
|
/ sum(weight for _, weight in available),
|
|
4,
|
|
)
|
|
if available
|
|
else None
|
|
)
|
|
|
|
|
|
def _style_factors(rows: list[dict[str, Any]]) -> None:
|
|
size = percentile_map(rows, "total_mv_billion", "desc")
|
|
large = [row for row in rows if (size.get(str(row["identifier"])) or 0) >= 0.7]
|
|
small = [row for row in rows if (size.get(str(row["identifier"])) or 1) <= 0.3]
|
|
large_return = mean([number(row.get("return_20d")) for row in large])
|
|
small_return = mean([number(row.get("return_20d")) for row in small])
|
|
prefer_large = (
|
|
large_return >= small_return
|
|
if large_return is not None and small_return is not None
|
|
else None
|
|
)
|
|
growth = [row for row in rows if (number(row.get("factor_growth_score")) or 0) >= 0.7]
|
|
value = [row for row in rows if (number(row.get("factor_value_score")) or 0) >= 0.7]
|
|
growth_return = mean([number(row.get("return_20d")) for row in growth])
|
|
value_return = mean([number(row.get("return_20d")) for row in value])
|
|
prefer_growth = (
|
|
growth_return >= value_return
|
|
if growth_return is not None and value_return is not None
|
|
else None
|
|
)
|
|
for row in rows:
|
|
size_rank = size.get(str(row["identifier"]))
|
|
row["style_size_fit"] = (
|
|
rounded(size_rank if prefer_large else 1 - size_rank, 4)
|
|
if size_rank is not None and prefer_large is not None
|
|
else None
|
|
)
|
|
row["style_growth_fit"] = (
|
|
row.get("factor_growth_score")
|
|
if prefer_growth
|
|
else row.get("factor_value_score")
|
|
if prefer_growth is not None
|
|
else None
|
|
)
|
|
row["style_fit_score"] = rounded(
|
|
mean([number(row.get("style_size_fit")), number(row.get("style_growth_fit"))]),
|
|
4,
|
|
)
|
|
|
|
|
|
def _market_height(rows: list[dict[str, Any]]) -> None:
|
|
current = [int(row["limit_streak"]) for row in rows if row.get("limit_streak") is not None]
|
|
previous = [
|
|
int(row["previous_limit_streak"])
|
|
for row in rows
|
|
if row.get("previous_limit_streak") is not None
|
|
]
|
|
current_height = max(current, default=0)
|
|
previous_height = max(previous, default=0)
|
|
for row in rows:
|
|
streak = row.get("limit_streak")
|
|
prior = row.get("previous_limit_streak")
|
|
if streak is None or prior is None:
|
|
row["is_market_height"] = None
|
|
row["new_space_board"] = None
|
|
continue
|
|
is_height = current_height >= 2 and int(streak) == current_height
|
|
row["is_market_height"] = is_height
|
|
row["new_space_board"] = is_height and not (
|
|
previous_height >= 2 and int(prior) == previous_height
|
|
)
|
|
|
|
|
|
def _rank(rows: list[dict[str, Any]], source: str, target: str, direction: str) -> None:
|
|
mapping = percentile_map(rows, source, direction)
|
|
for row in rows:
|
|
row[target] = rounded(mapping.get(str(row["identifier"])), 4)
|