feat: add versioned research templates and alpha variants
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"""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"]
|
||||
|
||||
def choices(key, instrument=None, region=None):
|
||||
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]
|
||||
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
|
||||
for universe in choices("universe", instrument, region):
|
||||
neutralizations = (
|
||||
choices("neutralization", instrument, region)
|
||||
if "neutralization" in children
|
||||
else []
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"instrument_type": instrument,
|
||||
"region": region,
|
||||
"universe": universe,
|
||||
"delay": delay,
|
||||
"neutralizations": neutralizations,
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
raise ValueError()
|
||||
return rows
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise HTTPException(502, "平台设置结构无法识别,未发布新快照") from None
|
||||
|
||||
|
||||
def normalize_availability(data):
|
||||
raw = data.get("availability")
|
||||
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 (
|
||||
item.get("instrumentType") == "EQUITY"
|
||||
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))
|
||||
content = {
|
||||
**normalize_availability(data),
|
||||
"field_id": body.field_id,
|
||||
"scope": body.scope.model_dump(),
|
||||
}
|
||||
return await self.publish(availability_key(body.field_id, body.scope), "availability", content)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Metadata snapshots used by research; refreshes never create simulations."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
|
||||
from ..research.workspace_contracts import FieldAvailabilityInput, OperatorAnnotation
|
||||
from ..security import require_auth
|
||||
from .contracts import Scope
|
||||
from .research_metadata import ResearchMetadata, availability_key
|
||||
|
||||
router = APIRouter(prefix="/api/v1/catalog", tags=["catalog"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
@router.get("/operators")
|
||||
async def operators(
|
||||
request: Request,
|
||||
q: str = "",
|
||||
category: str | None = None,
|
||||
favorite: bool = False,
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await ResearchMetadata(db).operators(q, category, favorite, limit, offset)
|
||||
|
||||
|
||||
@router.post("/operators/refresh")
|
||||
async def refresh_operators(request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await ResearchMetadata(db, request.app.state.runner.client).refresh_operators()
|
||||
|
||||
|
||||
@router.patch("/operators/{name}/research")
|
||||
async def operator_note(name: str, body: OperatorAnnotation, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await ResearchMetadata(db).annotate(name, body)
|
||||
|
||||
|
||||
@router.get("/setting-options")
|
||||
async def setting_options(request: Request):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await ResearchMetadata(db).get("settings")
|
||||
|
||||
|
||||
@router.post("/setting-options/refresh")
|
||||
async def refresh_settings(request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await ResearchMetadata(db, request.app.state.runner.client).refresh_settings()
|
||||
|
||||
|
||||
@router.get("/field-availability/{field_id}")
|
||||
async def availability(field_id: str, scope: Annotated[Scope, Query()], request: Request):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await ResearchMetadata(db).get(availability_key(field_id, scope))
|
||||
|
||||
|
||||
@router.post("/field-availability/refresh")
|
||||
async def refresh_availability(body: FieldAvailabilityInput, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await ResearchMetadata(db, request.app.state.runner.client).refresh_availability(body)
|
||||
Reference in New Issue
Block a user