2026-09-08 21:29:21 +08:00
|
|
|
"""Bounded metadata reads with all-or-nothing publication and separate annotations."""
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
|
|
|
|
|
from ..models import CatalogResource, OperatorNote, now
|
|
|
|
|
from ..research.serialization import encode_snapshot as jsonable_encoder
|
|
|
|
|
from ..worldquant import WqError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def upstream(operation):
|
|
|
|
|
try:
|
|
|
|
|
return await operation
|
|
|
|
|
except WqError as exc:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
409 if exc.code in ("disconnected", "verification_required") else 502, str(exc)
|
|
|
|
|
) from None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def availability_key(field_id, scope):
|
|
|
|
|
return "field:" + hashlib.sha256(f"{field_id}|{scope.key()}".encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def setting_rows(data):
|
|
|
|
|
"""Decode hierarchical OPTIONS choices; incomplete options are not invented."""
|
|
|
|
|
try:
|
|
|
|
|
children = data["actions"]["POST"]["settings"]["children"]
|
|
|
|
|
|
2026-09-12 12:11:53 +08:00
|
|
|
def choices(key, instrument=None, region=None, delay=None, universe=None):
|
2026-09-08 21:29:21 +08:00
|
|
|
value = children[key]["choices"]
|
|
|
|
|
if isinstance(value, dict) and "instrumentType" in value:
|
|
|
|
|
value = value["instrumentType"][instrument]
|
|
|
|
|
elif isinstance(value, dict) and instrument in value:
|
|
|
|
|
value = value[instrument]
|
|
|
|
|
if isinstance(value, dict) and "region" in value:
|
|
|
|
|
value = value["region"][region]
|
2026-09-12 12:11:53 +08:00
|
|
|
for dimension, selected in (("delay", delay), ("universe", universe)):
|
|
|
|
|
if isinstance(value, dict) and dimension in value:
|
|
|
|
|
branches = value[dimension]
|
|
|
|
|
if not isinstance(branches, dict):
|
|
|
|
|
raise ValueError()
|
|
|
|
|
value = branches.get(str(selected), branches.get(selected))
|
|
|
|
|
if not isinstance(value, list):
|
|
|
|
|
raise ValueError()
|
2026-09-08 21:29:21 +08:00
|
|
|
return [item["value"] for item in value]
|
|
|
|
|
|
|
|
|
|
rows = []
|
|
|
|
|
for instrument in choices("instrumentType"):
|
|
|
|
|
if instrument != "EQUITY":
|
|
|
|
|
continue
|
|
|
|
|
for region in choices("region", instrument):
|
|
|
|
|
for delay in choices("delay", instrument, region):
|
|
|
|
|
if type(delay) is not int or delay not in (0, 1):
|
|
|
|
|
continue
|
2026-09-12 12:11:53 +08:00
|
|
|
for universe in choices("universe", instrument, region, delay):
|
2026-09-08 21:29:21 +08:00
|
|
|
neutralizations = (
|
2026-09-12 12:11:53 +08:00
|
|
|
choices("neutralization", instrument, region, delay, universe)
|
2026-09-08 21:29:21 +08:00
|
|
|
if "neutralization" in children
|
|
|
|
|
else []
|
|
|
|
|
)
|
2026-09-12 12:11:53 +08:00
|
|
|
fields = {}
|
|
|
|
|
for key in (
|
|
|
|
|
"decay", "truncation", "pasteurization", "unitHandling",
|
|
|
|
|
"nanHandling", "language", "visualization", "maxTrade", "maxPosition",
|
|
|
|
|
):
|
|
|
|
|
definition = children.get(key)
|
|
|
|
|
if not isinstance(definition, dict):
|
|
|
|
|
continue
|
|
|
|
|
field = {}
|
|
|
|
|
if "choices" in definition:
|
|
|
|
|
field["choices"] = choices(key, instrument, region, delay, universe)
|
|
|
|
|
if any(type(v) not in (str, int, float, bool) for v in field["choices"]):
|
|
|
|
|
raise ValueError()
|
|
|
|
|
for source, target in (("min_value", "minimum"), ("max_value", "maximum")):
|
|
|
|
|
if type(definition.get(source)) in (int, float):
|
|
|
|
|
field[target] = definition[source]
|
|
|
|
|
fields[key] = field
|
2026-09-08 21:29:21 +08:00
|
|
|
rows.append(
|
|
|
|
|
{
|
|
|
|
|
"instrument_type": instrument,
|
|
|
|
|
"region": region,
|
|
|
|
|
"universe": universe,
|
|
|
|
|
"delay": delay,
|
|
|
|
|
"neutralizations": neutralizations,
|
2026-09-12 12:11:53 +08:00
|
|
|
"fields": fields,
|
2026-09-08 21:29:21 +08:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
if not rows:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
return rows
|
|
|
|
|
except (KeyError, TypeError, ValueError):
|
|
|
|
|
raise HTTPException(502, "平台设置结构无法识别,未发布新快照") from None
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 23:32:52 +08:00
|
|
|
def normalize_availability(data, *, instrument_type=None):
|
|
|
|
|
"""Use the request's instrument only for the platform field-detail `data` form.
|
|
|
|
|
|
|
|
|
|
Legacy availability rows must still state their own instrument. Missing market,
|
|
|
|
|
delay or universe never inherits the requested scope.
|
|
|
|
|
"""
|
2026-09-08 21:29:21 +08:00
|
|
|
raw = data.get("availability")
|
2026-09-08 23:32:52 +08:00
|
|
|
detail_form = raw is None and isinstance(data.get("data"), list)
|
|
|
|
|
if detail_form:
|
|
|
|
|
raw = data["data"]
|
2026-09-08 21:29:21 +08:00
|
|
|
if not isinstance(raw, list):
|
|
|
|
|
return {"status": "needs_review", "items": [], "reason": "平台未提供可识别的 availability 列表"}
|
|
|
|
|
rows, malformed = [], False
|
|
|
|
|
for item in raw:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
malformed = True
|
|
|
|
|
continue
|
|
|
|
|
universes = item.get("universe", item.get("universes"))
|
|
|
|
|
universes = universes if isinstance(universes, list) else [universes]
|
|
|
|
|
for universe in universes:
|
|
|
|
|
if (
|
2026-09-08 23:32:52 +08:00
|
|
|
item.get("instrumentType", instrument_type if detail_form else None) == "EQUITY"
|
2026-09-08 21:29:21 +08:00
|
|
|
and type(item.get("delay")) is int
|
|
|
|
|
and item["delay"] in (0, 1)
|
|
|
|
|
and isinstance(item.get("region"), str)
|
|
|
|
|
and isinstance(universe, str)
|
|
|
|
|
):
|
|
|
|
|
rows.append(
|
|
|
|
|
{
|
|
|
|
|
"instrument_type": "EQUITY",
|
|
|
|
|
"region": item["region"],
|
|
|
|
|
"delay": item["delay"],
|
|
|
|
|
"universe": universe,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
malformed = True
|
|
|
|
|
return {
|
|
|
|
|
"status": "available" if rows and not malformed else "needs_review",
|
|
|
|
|
"items": rows,
|
|
|
|
|
"reason": "可用性列表包含不完整项" if malformed else "",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ResearchMetadata:
|
|
|
|
|
def __init__(self, db, client=None):
|
|
|
|
|
self.db, self.client = db, client
|
|
|
|
|
|
|
|
|
|
async def publish(self, key, kind, content):
|
|
|
|
|
row = await self.db.get(CatalogResource, key)
|
|
|
|
|
if row:
|
|
|
|
|
row.content, row.fetched_at = content, now()
|
|
|
|
|
else:
|
|
|
|
|
row = CatalogResource(key=key, kind=kind, content=content)
|
|
|
|
|
self.db.add(row)
|
|
|
|
|
await self.db.flush()
|
|
|
|
|
return self.output(row)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def output(row):
|
|
|
|
|
return jsonable_encoder({"key": row.key, "content": row.content, "fetched_at": row.fetched_at})
|
|
|
|
|
|
|
|
|
|
async def get(self, key):
|
|
|
|
|
row = await self.db.get(CatalogResource, key)
|
|
|
|
|
return self.output(row) if row else {"key": key, "content": {}, "fetched_at": None}
|
|
|
|
|
|
|
|
|
|
async def refresh_operators(self):
|
|
|
|
|
items, seen = [], set()
|
|
|
|
|
for offset in range(0, 10000, 100):
|
|
|
|
|
page = await upstream(self.client.operators(offset))
|
|
|
|
|
values = page if isinstance(page, list) else page.get("results")
|
|
|
|
|
if not isinstance(values, list):
|
|
|
|
|
raise HTTPException(502, "算子目录格式无法识别,保留原快照")
|
|
|
|
|
for item in values:
|
|
|
|
|
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
|
|
|
|
|
raise HTTPException(502, "算子目录缺少名称,保留原快照")
|
|
|
|
|
if item["name"] in seen:
|
|
|
|
|
raise HTTPException(502, "算子分页重复,未发布不完整目录")
|
|
|
|
|
seen.add(item["name"])
|
|
|
|
|
items.append(
|
|
|
|
|
{
|
|
|
|
|
key: item.get(key)
|
|
|
|
|
for key in (
|
|
|
|
|
"name",
|
|
|
|
|
"category",
|
|
|
|
|
"description",
|
|
|
|
|
"definition",
|
|
|
|
|
"example",
|
|
|
|
|
"scope",
|
|
|
|
|
"type",
|
|
|
|
|
"parameters",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
isinstance(page, list)
|
|
|
|
|
or (isinstance(page.get("count"), int) and offset + len(values) >= page["count"])
|
|
|
|
|
or (not page.get("next") and len(values) < 100)
|
|
|
|
|
):
|
|
|
|
|
return await self.publish("operators", "operators", {"items": items})
|
|
|
|
|
if not values:
|
|
|
|
|
raise HTTPException(502, "算子分页提前结束")
|
|
|
|
|
raise HTTPException(502, "算子分页超过本地限制,未发布新快照")
|
|
|
|
|
|
|
|
|
|
async def operators(self, q="", category=None, favorite=False, limit=25, offset=0):
|
|
|
|
|
snapshot = await self.get("operators")
|
|
|
|
|
notes = {r.name: r for r in await self.db.scalars(select(OperatorNote))}
|
|
|
|
|
rows = []
|
|
|
|
|
for item in snapshot["content"].get("items", []):
|
|
|
|
|
note = notes.get(item["name"])
|
|
|
|
|
if q.lower() not in json.dumps(item, ensure_ascii=False).lower() or (
|
|
|
|
|
category and item["category"] != category
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
if favorite and not (note and note.favorite):
|
|
|
|
|
continue
|
|
|
|
|
rows.append(
|
|
|
|
|
{
|
|
|
|
|
**item,
|
|
|
|
|
"local": {
|
|
|
|
|
"note": note.note if note else "",
|
|
|
|
|
"favorite": note.favorite if note else False,
|
|
|
|
|
"version": note.version if note else 0,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"items": rows[offset : offset + limit],
|
|
|
|
|
"total": len(rows),
|
|
|
|
|
"limit": limit,
|
|
|
|
|
"offset": offset,
|
|
|
|
|
"fetched_at": snapshot["fetched_at"],
|
|
|
|
|
"categories": sorted(
|
|
|
|
|
{str(i.get("category")) for i in snapshot["content"].get("items", []) if i.get("category")}
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def annotate(self, name, body):
|
|
|
|
|
snapshot = await self.get("operators")
|
|
|
|
|
if name not in {i["name"] for i in snapshot["content"].get("items", [])}:
|
|
|
|
|
raise HTTPException(404, "算子不在已同步目录中")
|
|
|
|
|
if body.version == 0:
|
|
|
|
|
if await self.db.get(OperatorNote, name):
|
|
|
|
|
raise HTTPException(409, "备注已变化")
|
|
|
|
|
self.db.add(OperatorNote(name=name, note=body.note, favorite=body.favorite))
|
|
|
|
|
try:
|
|
|
|
|
await self.db.flush()
|
|
|
|
|
except IntegrityError:
|
|
|
|
|
raise HTTPException(409, "备注已变化,请保留草稿并刷新") from None
|
|
|
|
|
else:
|
|
|
|
|
result = await self.db.execute(
|
|
|
|
|
update(OperatorNote)
|
|
|
|
|
.where(OperatorNote.name == name, OperatorNote.version == body.version)
|
|
|
|
|
.values(note=body.note, favorite=body.favorite, version=body.version + 1)
|
|
|
|
|
)
|
|
|
|
|
if result.rowcount != 1:
|
|
|
|
|
raise HTTPException(409, "备注已变化,请保留草稿并刷新")
|
|
|
|
|
return {"ok": True, "version": body.version + 1}
|
|
|
|
|
|
|
|
|
|
async def refresh_settings(self):
|
|
|
|
|
data = await upstream(self.client.research_setting_options())
|
|
|
|
|
return await self.publish("settings", "settings", {"items": setting_rows(data)})
|
|
|
|
|
|
|
|
|
|
async def refresh_availability(self, body):
|
|
|
|
|
data = await upstream(self.client.field_availability(body.field_id, body.scope))
|
2026-09-08 23:32:52 +08:00
|
|
|
if data.get("id") is not None and data["id"] != body.field_id:
|
|
|
|
|
raise HTTPException(502, "平台返回字段与请求不一致,保留原可用性快照")
|
2026-09-08 21:29:21 +08:00
|
|
|
content = {
|
2026-09-08 23:32:52 +08:00
|
|
|
**normalize_availability(data, instrument_type=body.scope.instrument_type),
|
2026-09-08 21:29:21 +08:00
|
|
|
"field_id": body.field_id,
|
|
|
|
|
"scope": body.scope.model_dump(),
|
|
|
|
|
}
|
|
|
|
|
return await self.publish(availability_key(body.field_id, body.scope), "availability", content)
|