Files
worldquant-alpha-system/backend/app/alphas.py
T
yuxuanhui 8444e3055e
Deploy production / deploy (push) Successful in 56s
feat: add PPAC candidate tab and status
2026-09-13 12:57:38 +08:00

377 lines
16 KiB
Python

"""Normalize upstream business data, without making platform or research decisions."""
import math
import re
from datetime import datetime
from sqlalchemy import or_, select, update
from .models import Alpha, Research, ResearchTag, SelfCorrelation, now
from .platform_checks import check_result, split_checks, submission_limits
from .research.provenance import source_alpha_ids
METRIC_FIELDS = (
"sharpe", "fitness", "returns", "turnover", "margin", "drawdown",
"sub_universe_sharpe", "robust_universe_sharpe", "two_year_sharpe", "prod_correlation", "pnl",
)
def failed_checks(checks):
"""Return failed Alpha check names, excluding submission limits and local correlation."""
return [
check.get("name") if isinstance(check.get("name"), str) else "未命名检查"
for check in split_checks(checks)[0] if isinstance(check, dict) and check_result(check) == "FAIL"
] if isinstance(checks, list) else []
def snapshot_columns(settings, metrics, checks, *, checked=False):
"""Derive list fields from a platform snapshot, preserving missing metrics as null.
Submission limits are excluded. Only explicit Alpha FAIL results count.
Sync snapshots with no failures are PRE_CHECK; a completed explicit /check
with no failures is PASS. WARNING/PENDING do not count as failures, matching
the legacy workflow. Empty, malformed or unknown results remain PENDING.
A sole PURE_POWER_POOL_THEME failure is a PPAC candidate, not confirmation
of current submission or activity eligibility. Raw failures stay available.
"""
settings = settings if isinstance(settings, dict) else {}
metrics = metrics if isinstance(metrics, dict) else {}
blocked = submission_limits(checks)["status"] == "blocked"
checks, _ = split_checks(checks)
valid = [check for check in checks if isinstance(check, dict)]
failed_names = failed_checks(checks)
failures = len(failed_names)
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
if failed_names == ["PURE_POWER_POOL_THEME"]:
check_type = "PPAC_CANDIDATE"
elif failures:
check_type = "FAIL_1" if failures == 1 else "FAIL_2"
elif not checks or len(valid) != len(checks) or any(check_result(check) not in ("PASS", "WARNING", "PENDING") for check in valid):
check_type = "PENDING"
else:
check_type = "PASS" if checked else "PRE_CHECK"
# /check values are freshest; submitted snapshots also expose a scalar in IS.
prod_correlation = number(by_name.get("PROD_CORRELATION", {}).get("value"))
if prod_correlation is None:
prod_correlation = number(metrics.get("prodCorrelation"))
neutralization = settings.get("neutralization")
return {
"check_type": check_type,
"submission_blocked": blocked,
"neutralization": neutralization if isinstance(neutralization, str) else None,
"pnl": number(metrics.get("pnl")),
"prod_correlation": prod_correlation,
**{
field: number(by_name.get(name, {}).get("value"))
for field, name in (
("sub_universe_sharpe", "LOW_SUB_UNIVERSE_SHARPE"),
("robust_universe_sharpe", "LOW_ROBUST_UNIVERSE_SHARPE"),
("two_year_sharpe", "LOW_2Y_SHARPE"),
)
},
}
def check_summary(checks, *, check_type):
"""Separate cached Alpha findings from submission limits; infer no live eligibility."""
return {
"check_type": check_type,
"failed_checks": failed_checks(checks),
"submission_limits": submission_limits(checks),
"meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项;PPAC_CANDIDATE 为唯一失败项是 PURE_POWER_POOL_THEME 的候选。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交",
}
def submission_condition(submission):
"""Match the platform list contract; a missing status is never assumed submitted."""
return Alpha.status == "UNSUBMITTED" if submission == "UNSUBMITTED" else Alpha.status != "UNSUBMITTED"
async def invalidate_correlations(db, alpha_id, regions=()):
"""A changed baseline or PnL invalidates local conclusions without touching platform checks."""
await db.execute(
update(SelfCorrelation)
.where(or_(SelfCorrelation.alpha_id == alpha_id, SelfCorrelation.region.in_(regions)))
.values(stale=True)
)
SENSITIVE_KEYS = {
"password",
"token",
"cookies",
"cookie",
"authorization",
"credentials",
"secret",
"accesstoken",
"refreshtoken",
"sessiontoken",
"clientsecret",
"authorizationheader",
"setcookie",
"apikey",
"csrftoken",
"xsrftoken",
"authentication",
}
def sanitize(value):
if isinstance(value, dict):
return {
k: sanitize(v) for k, v in value.items() if re.sub(r"[^a-z]", "", k.lower()) not in SENSITIVE_KEYS
}
if isinstance(value, list):
return [sanitize(v) for v in value]
if isinstance(value, float) and not math.isfinite(value):
return None
return value
def number(value):
if value is None or isinstance(value, bool):
return None
try:
result = float(value)
return result if math.isfinite(result) else None
except (ValueError, TypeError):
return None
def date(value):
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None
except (ValueError, TypeError, AttributeError):
return None
def code(value):
return value.get("code") if isinstance(value, dict) else value if isinstance(value, str) else None
async def upsert_alpha(db, raw: dict):
alpha_id = raw.get("id")
if not isinstance(alpha_id, str) or not alpha_id:
raise ValueError("Alpha 数据缺少 ID")
item = await db.get(Alpha, alpha_id)
previous_region = item.region if item else None
previous_status = item.status if item else None
if item is None:
item = Alpha(id=alpha_id)
db.add(item)
settings = raw.get("settings") or {}
metrics = raw.get("is") if isinstance(raw.get("is"), dict) else {}
item.name = raw.get("name")
item.expression = code(raw.get("regular"))
item.selection, item.combo = code(raw.get("selection")), code(raw.get("combo"))
item.alpha_type, item.language = raw.get("type"), settings.get("language")
item.stage, item.status, item.hidden = raw.get("stage"), raw.get("status"), raw.get("hidden") is True
item.region, item.universe = settings.get("region"), settings.get("universe")
if previous_region != item.region or previous_status != item.status:
regions = {
region
for region, status in ((previous_region, previous_status), (item.region, item.status))
if region and status and status != "UNSUBMITTED"
}
await invalidate_correlations(db, alpha_id, regions)
item.settings, item.is_metrics = sanitize(settings), sanitize(metrics)
item.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {}
item.checks = sanitize(metrics.get("checks") or raw.get("checks") or [])
for key, value in snapshot_columns(item.settings, item.is_metrics, item.checks).items():
setattr(item, key, value)
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
setattr(item, key, number(metrics.get(key)))
item.date_created, item.date_submitted = date(raw.get("dateCreated")), date(raw.get("dateSubmitted"))
item.synced_at, item.raw = now(), sanitize(raw)
await db.flush()
if await db.get(Research, alpha_id) is None:
db.add(Research(alpha_id=alpha_id))
return item
def list_statement(filters):
query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id)
if filters.management_scope == "super":
query = query.where(Alpha.alpha_type == "SUPER")
elif filters.management_scope == "non_super":
query = query.where(or_(Alpha.alpha_type != "SUPER", Alpha.alpha_type.is_(None)))
if filters.submission:
query = query.where(submission_condition(filters.submission))
if filters.submission_blocked is not None:
query = query.where(Alpha.submission_blocked == filters.submission_blocked)
if filters.submission_blocked:
query = query.where(submission_condition("UNSUBMITTED"))
if filters.ppac_candidate is not None:
candidate = Alpha.check_type == "PPAC_CANDIDATE"
query = query.where(candidate if filters.ppac_candidate else ~candidate)
if filters.ppac_candidate:
query = query.where(submission_condition("UNSUBMITTED"))
if (filters.local_correlation_status is not None or filters.local_correlation_min is not None
or filters.local_correlation_max is not None):
# One cache row per Alpha keeps totals/export stable; stale overrides the displayed status.
query = query.outerjoin(SelfCorrelation, SelfCorrelation.alpha_id == Alpha.id)
status = filters.local_correlation_status
if status == "not_cached":
query = query.where(SelfCorrelation.alpha_id.is_(None))
elif status == "stale":
query = query.where(SelfCorrelation.stale.is_(True))
elif status is not None:
query = query.where(SelfCorrelation.stale.is_(False), SelfCorrelation.result["status"].as_string() == status)
coefficient = SelfCorrelation.result["max_correlation"].as_float()
if filters.local_correlation_min is not None:
query = query.where(coefficient >= filters.local_correlation_min)
if filters.local_correlation_max is not None:
query = query.where(coefficient <= filters.local_correlation_max)
source_filters = {k: getattr(filters, k) for k in ("source", "source_reference", "research_id", "backtest_run_id")}
if any(source_filters.values()):
query = query.where(Alpha.id.in_(source_alpha_ids(**source_filters)))
q = filters.q
if q:
pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
query = query.where(
or_(
*(
getattr(Alpha, f).ilike(pattern, escape="\\")
for f in ("id", "name", "expression", "selection", "combo")
)
)
)
for name in ("region", "universe", "alpha_type", "language", "status", "stage", "hidden", "check_type", "neutralization"):
value = getattr(filters, name)
if value is not None:
query = query.where(getattr(Alpha, name) == value)
if filters.research_state:
query = query.where(Research.state == filters.research_state)
if filters.favorite is not None:
query = query.where(Research.favorite == filters.favorite)
if filters.tag:
query = query.where(Alpha.id.in_(select(ResearchTag.alpha_id).where(ResearchTag.tag == filters.tag)))
if filters.created_from:
query = query.where(Alpha.date_created >= filters.created_from)
if filters.created_to:
query = query.where(Alpha.date_created <= filters.created_to)
for name in METRIC_FIELDS:
for suffix, compare in (("min", "ge"), ("max", "le")):
value = getattr(filters, f"{name}_{suffix}")
if value is not None:
column = getattr(Alpha, name)
query = query.where(column >= value if compare == "ge" else column <= value)
return query
def sorted_statement(query, sort, direction):
column = getattr(Alpha, sort)
order = column.desc() if direction == "desc" else column.asc()
return query.order_by(order.nullslast(), Alpha.id.asc())
def summary(item: Alpha, research: Research):
keys = (
"id",
"name",
"alpha_type",
"language",
"stage",
"status",
"hidden",
"region",
"universe",
"sharpe",
"fitness",
"returns",
"turnover",
"margin",
"drawdown",
"date_created",
"date_submitted",
"synced_at",
"check_type",
"neutralization",
"sub_universe_sharpe",
"robust_universe_sharpe",
"two_year_sharpe",
"prod_correlation",
"pnl",
)
result = {k: getattr(item, k) for k in keys}
result["failed_checks"] = failed_checks(item.checks)
result["expression_preview"] = (item.expression or item.selection or "")[:240]
result["selection_preview"], result["combo_preview"] = (item.selection or "")[:240], (item.combo or "")[:240]
if item.alpha_type == "SUPER":
from .superalpha.evidence import parse_components
components = parse_components(item.raw.get("components", item.raw.get("selectedAlphas")))
result["component_count"] = len(components["components"]) if components["complete"] else None
result["research"] = {
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version")
}
return result
def pnl_points(raw, column=None):
"""Use schema column names, preserving missing values rather than creating zero PnL."""
records = raw.get("records")
schema = raw.get("schema") or {}
properties = schema.get("properties", []) if isinstance(schema, dict) else schema
if isinstance(properties, dict):
names = list(properties)
else:
names = [p.get("name", "") if isinstance(p, dict) else str(p) for p in properties]
normalized = [name.lower() for name in names]
value_names = (column,) if column else ("pnl", "value")
if not isinstance(records, list):
raise ValueError("PnL 缺少 records")
points = []
for row in records:
if isinstance(row, dict):
row = {str(k).lower(): v for k, v in row.items()}
timestamp = next((row[k] for k in ("date", "datetime", "timestamp") if k in row), None)
value = next((row[k] for k in value_names if k in row), None)
else:
date_i = next(
(i for i, n in enumerate(normalized) if n in ("date", "datetime", "timestamp")), None
)
pnl_i = next((i for i, n in enumerate(normalized) if n in value_names), None)
if date_i is None or pnl_i is None or not isinstance(row, list) or len(row) <= date_i:
raise ValueError("PnL schema 无法识别日期或数值列")
if len(row) <= pnl_i and column is None:
raise ValueError("PnL schema 无法识别日期或数值列")
timestamp, value = row[date_i], row[pnl_i] if len(row) > pnl_i else None
if timestamp is not None:
if isinstance(timestamp, (int, float)):
from datetime import timezone
timestamp = datetime.fromtimestamp(
timestamp / 1000 if timestamp > 1e11 else timestamp, tz=timezone.utc
).isoformat()
points.append({"date": str(timestamp), "value": number(value)})
return sorted(points, key=lambda p: p["date"])
def glb_pnl_series(raw, points):
"""Read GLB display series from cached raw data; keep the correlation baseline intact.
Missing columns are omitted, while missing values remain gaps. Legacy caches
containing only normalized points still return their overall PnL.
"""
series = [{"id": "pnl", "label": "总体 PnL", "points": points}]
schema = raw.get("schema") or {}
properties = schema.get("properties", []) if isinstance(schema, dict) else schema
names = properties if isinstance(properties, dict) else [
p.get("name", "") if isinstance(p, dict) else str(p) for p in properties
]
available = {name.lower() for name in names}
for row in raw.get("records", []):
if isinstance(row, dict):
available.update(str(key).lower() for key in row)
for column, label in (
("investability-constrained-pnl", "可投资性约束 PnL"),
("amer-pnl", "AMER PnL"),
("apac-pnl", "APAC PnL"),
("emea-pnl", "EMEA PnL"),
):
if column in available:
series.append({"id": column, "label": label, "points": pnl_points(raw, column)})
return series