125 lines
5.1 KiB
Python
125 lines
5.1 KiB
Python
|
|
"""Resolve fixed data inputs and produce previews without submitting simulations.
|
||
|
|
|
||
|
|
Callers own authorization and transactions. Binding checks establish provenance,
|
||
|
|
not FASTEXPR operator semantics or the account's current platform permissions.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from fastapi import HTTPException
|
||
|
|
from sqlalchemy import select
|
||
|
|
|
||
|
|
from ..backtests.contracts import Candidate, DraftInput, PreviewInput, Source
|
||
|
|
from ..catalog.contracts import EntryOutput, InputPreparation
|
||
|
|
from ..catalog.service import Catalog
|
||
|
|
from ..models import CatalogEntry
|
||
|
|
from .contracts import PLACEHOLDER
|
||
|
|
|
||
|
|
|
||
|
|
class ResearchBuilder:
|
||
|
|
def __init__(self, db, backtests):
|
||
|
|
self.db = db
|
||
|
|
self.catalog = Catalog(db)
|
||
|
|
self.backtests = backtests
|
||
|
|
|
||
|
|
async def select_input(self, body):
|
||
|
|
"""Fix explicit fields in one published version; reject missing or stale members."""
|
||
|
|
collection = await self.catalog.collection(body.scope, body.dataset_id)
|
||
|
|
chosen = set(body.field_ids)
|
||
|
|
if len(chosen) != len(body.field_ids) or not chosen.issubset(collection["field_ids"]):
|
||
|
|
raise HTTPException(422, "字段选择含重复、未知或其他数据集字段")
|
||
|
|
saved = await self.catalog.prepare(
|
||
|
|
InputPreparation(
|
||
|
|
scope=body.scope,
|
||
|
|
dataset_id=body.dataset_id,
|
||
|
|
collection_version=body.collection_version,
|
||
|
|
selection="explicit",
|
||
|
|
excluded_ids=[field for field in collection["field_ids"] if field not in chosen],
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return await self.input_page(saved["id"])
|
||
|
|
|
||
|
|
async def input_page(self, input_id, limit=25, offset=0, q="", field_type=None):
|
||
|
|
"""Read the saved version, including field descriptions, with explicit pagination."""
|
||
|
|
saved = await self.catalog.input(input_id)
|
||
|
|
ids = [
|
||
|
|
field
|
||
|
|
for field in saved["field_ids"]
|
||
|
|
if q.lower() in field.lower()
|
||
|
|
and (field_type is None or saved["field_types"].get(field) == field_type)
|
||
|
|
]
|
||
|
|
page = ids[offset : offset + limit]
|
||
|
|
entries = {
|
||
|
|
row.id: row
|
||
|
|
for row in await self.db.scalars(
|
||
|
|
select(CatalogEntry).where(
|
||
|
|
CatalogEntry.batch_id == saved["collection_version"], CatalogEntry.id.in_(page)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
**{
|
||
|
|
k: saved[k]
|
||
|
|
for k in ("id", "scope", "dataset_id", "collection_version", "selection", "created_at")
|
||
|
|
},
|
||
|
|
"field_count": len(saved["field_ids"]),
|
||
|
|
"items": [
|
||
|
|
EntryOutput.model_validate(entries[field], from_attributes=True).model_dump()
|
||
|
|
for field in page
|
||
|
|
],
|
||
|
|
"total": len(ids),
|
||
|
|
"limit": limit,
|
||
|
|
"offset": offset,
|
||
|
|
"has_more": offset + limit < len(ids),
|
||
|
|
}
|
||
|
|
|
||
|
|
async def prepare(self, body):
|
||
|
|
"""Bind templates against an immutable input, then reuse the fixed-preview interface.
|
||
|
|
|
||
|
|
Raises HTTPException(422) for wrong scope, membership or declared type.
|
||
|
|
No expression execution or implicit cleaning/aggregation takes place here.
|
||
|
|
"""
|
||
|
|
saved = await self.catalog.input(body.template_input_id)
|
||
|
|
scope = saved["scope"]
|
||
|
|
candidates = []
|
||
|
|
for item in body.candidates:
|
||
|
|
settings = item.settings
|
||
|
|
if (
|
||
|
|
settings.instrumentType != scope["instrument_type"]
|
||
|
|
or settings.region != scope["region"]
|
||
|
|
or settings.universe != scope["universe"]
|
||
|
|
or settings.delay != scope["delay"]
|
||
|
|
):
|
||
|
|
raise HTTPException(422, "候选模拟参数与输入快照的研究范围不一致")
|
||
|
|
for binding in item.bindings.values():
|
||
|
|
if binding.field_id not in saved["field_ids"]:
|
||
|
|
raise HTTPException(422, "绑定字段不属于该输入快照,不能使用被排除或其他数据集字段")
|
||
|
|
if saved["field_types"].get(binding.field_id) != binding.field_type:
|
||
|
|
raise HTTPException(422, "字段类型声明与输入快照不一致,未知类型不能自动构建")
|
||
|
|
expression = PLACEHOLDER.sub(
|
||
|
|
lambda match: item.bindings[match.group(1)].field_id, item.expression_template
|
||
|
|
)
|
||
|
|
if len(expression) > 20000:
|
||
|
|
raise HTTPException(422, "绑定后的表达式超过 20000 字符")
|
||
|
|
candidates.append(
|
||
|
|
Candidate(
|
||
|
|
client_item_id=item.client_item_id,
|
||
|
|
expression=expression,
|
||
|
|
settings=settings,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
source = Source.model_validate(
|
||
|
|
{
|
||
|
|
**body.source.model_dump(),
|
||
|
|
"template_input_id": saved["id"],
|
||
|
|
"hypothesis": body.hypothesis,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return await self.backtests.preview(
|
||
|
|
PreviewInput(
|
||
|
|
inline=DraftInput(
|
||
|
|
name=body.name,
|
||
|
|
source=source,
|
||
|
|
candidates=candidates,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|