Files
Starriv aeacabcf3f feat(scoring): add entry timing and AI opinion overlays
Add model versioning and forward-log validation, tighten scanner data flows, and cover the new contracts and decision paths with regression tests.
2026-07-10 11:30:35 +08:00

304 lines
12 KiB
Python

#!/usr/bin/env python3
"""Validation helpers for multi-agent JSON artifacts.
The validators are intentionally lightweight and standard-library only. They
check that sub-agent handoff files are structurally safe before the main agent
passes them to ``indicators.py``.
"""
from __future__ import annotations
from typing import Any
CONTRACT_VERSION = "worth-buy-stocks.agent.v1"
KIND_NEWS = "news_context"
KIND_ACCOUNT = "account_context"
KIND_FINNHUB = "finnhub_context"
KIND_BARS = "bars"
KIND_SNAPSHOT = "snapshot_context"
KIND_RESULT = "result"
KIND_AI_OPINION = "ai_opinion"
KINDS = (KIND_NEWS, KIND_ACCOUNT, KIND_FINNHUB, KIND_BARS, KIND_SNAPSHOT, KIND_RESULT, KIND_AI_OPINION)
UNAVAILABLE_STATUSES = {"unavailable", "unauthorized", "rate_limited"}
META_KEYS = {
"contract_version", "kind", "status", "reason", "as_of", "provider",
"feed", "adjustment", "account", "positions", "bars",
}
class ContractError(ValueError):
"""Raised when an agent artifact violates the expected shape."""
def _fail(path: str, message: str) -> None:
raise ContractError(f"{path}: {message}")
def _expect(condition: bool, path: str, message: str) -> None:
if not condition:
_fail(path, message)
def _is_obj(value: Any) -> bool:
return isinstance(value, dict)
def _status(payload: dict[str, Any], path: str, allowed_extra: set[str] | None = None) -> str:
status = str(payload.get("status") or "ok")
allowed = {"ok"} | UNAVAILABLE_STATUSES | (allowed_extra or set())
_expect(status in allowed, path, f"status must be one of {sorted(allowed)}")
if status != "ok":
_expect(bool(str(payload.get("reason") or "").strip()), path, "non-ok status requires reason")
return status
def _symbols_from_direct_or_wrapper(payload: dict[str, Any]) -> dict[str, Any]:
if isinstance(payload.get("symbols"), dict):
return payload["symbols"]
return {
str(k).strip().upper(): v
for k, v in payload.items()
if k not in META_KEYS and isinstance(v, dict)
}
def _validate_symbol_key(symbol: str, path: str) -> None:
_expect(bool(symbol.strip()), path, "symbol key must be non-empty")
_expect(symbol == symbol.upper(), path, "symbol key must be uppercase")
def _validate_sources(sources: Any, path: str) -> None:
if sources is None:
return
_expect(isinstance(sources, list), path, "sources must be a list")
for i, source in enumerate(sources):
sp = f"{path}[{i}]"
_expect(_is_obj(source), sp, "source must be an object")
if "id" in source:
_expect(bool(str(source["id"]).strip()), f"{sp}.id", "id must be non-empty")
if "url" in source:
_expect(str(source["url"]).startswith(("http://", "https://")), f"{sp}.url", "url must be http(s)")
def _validate_red_flags(red_flags: Any, path: str) -> None:
if red_flags is None:
return
_expect(isinstance(red_flags, list), path, "red_flags must be a list")
for i, flag in enumerate(red_flags):
fp = f"{path}[{i}]"
_expect(_is_obj(flag), fp, "red flag must be an object")
sev = str(flag.get("severity") or "").lower()
_expect(sev in {"high", "medium", "low"}, f"{fp}.severity", "severity must be high, medium, or low")
_expect(bool(str(flag.get("type") or "").strip()), f"{fp}.type", "type is required")
_expect(bool(str(flag.get("note") or "").strip()), f"{fp}.note", "note is required")
def validate_news_context(payload: Any) -> dict[str, Any]:
_expect(_is_obj(payload), "$", "news_context must be an object")
status = _status(payload, "$")
if status != "ok":
return {"symbols_count": 0}
symbols = _symbols_from_direct_or_wrapper(payload)
for symbol, ctx in symbols.items():
sp = f"$.symbols.{symbol}"
_validate_symbol_key(symbol, sp)
_expect(_is_obj(ctx), sp, "symbol context must be an object")
data_trust = ctx.get("data_trust")
if data_trust is not None:
_expect(
str(data_trust).lower() in {"ok", "suspect", "unverified", "stale", "bad", "unknown"},
f"{sp}.data_trust",
"data_trust must be ok/suspect/unverified/stale/bad/unknown",
)
_validate_sources(ctx.get("sources"), f"{sp}.sources")
_validate_red_flags(ctx.get("red_flags"), f"{sp}.red_flags")
return {"symbols_count": len(symbols)}
def validate_account_context(payload: Any) -> dict[str, Any]:
_expect(_is_obj(payload), "$", "account_context must be an object")
status = _status(payload, "$")
if status != "ok":
return {"positions_count": 0}
_expect(_is_obj(payload.get("account")), "$.account", "account must be an object")
positions = payload.get("positions", [])
_expect(isinstance(positions, (list, dict)), "$.positions", "positions must be a list or object")
items = positions if isinstance(positions, list) else list(positions.values())
for i, pos in enumerate(items):
pp = f"$.positions[{i}]"
_expect(_is_obj(pos), pp, "position must be an object")
if pos:
_expect(bool(str(pos.get("symbol") or "").strip()), f"{pp}.symbol", "symbol is required")
return {"positions_count": len(items)}
def validate_finnhub_context(payload: Any) -> dict[str, Any]:
_expect(_is_obj(payload), "$", "finnhub_context must be an object")
status = _status(payload, "$")
if status != "ok":
return {"symbols_count": 0}
symbols = payload.get("symbols")
_expect(isinstance(symbols, dict), "$.symbols", "symbols must be an object")
for symbol, ctx in symbols.items():
sp = f"$.symbols.{symbol}"
_validate_symbol_key(str(symbol), sp)
_expect(_is_obj(ctx), sp, "symbol context must be an object")
_status(ctx, sp)
for key in ("news", "earnings", "data_flags"):
if key in ctx:
_expect(isinstance(ctx[key], list), f"{sp}.{key}", f"{key} must be a list")
for key in ("quote", "profile"):
if key in ctx:
_expect(_is_obj(ctx[key]), f"{sp}.{key}", f"{key} must be an object")
return {"symbols_count": len(symbols)}
def _validate_number(value: Any, path: str) -> None:
_expect(isinstance(value, (int, float)) and not isinstance(value, bool), path, "must be a number")
def validate_bars(payload: Any) -> dict[str, Any]:
_expect(_is_obj(payload), "$", "bars artifact must be an object")
status = _status(payload, "$")
_expect(status == "ok", "$.status", "bars artifact must have status=ok")
bars = payload.get("bars")
_expect(isinstance(bars, dict) and bars, "$.bars", "bars must be a non-empty object")
rows = 0
for symbol, blist in bars.items():
sp = f"$.bars.{symbol}"
_validate_symbol_key(str(symbol), sp)
_expect(isinstance(blist, list), sp, "symbol bars must be a list")
for i, bar in enumerate(blist):
bp = f"{sp}[{i}]"
_expect(_is_obj(bar), bp, "bar must be an object")
for key in ("t", "o", "h", "l", "c", "v"):
_expect(key in bar, f"{bp}.{key}", "required bar field is missing")
_expect(bool(str(bar["t"]).strip()), f"{bp}.t", "timestamp is required")
for key in ("o", "h", "l", "c", "v"):
_validate_number(bar[key], f"{bp}.{key}")
rows += 1
return {"symbols_count": len(bars), "bars_count": rows}
def validate_result(payload: Any) -> dict[str, Any]:
_expect(_is_obj(payload), "$", "result must be an object")
if "error" in payload:
_fail("$.error", "result contains top-level error")
symbols = payload.get("symbols")
_expect(isinstance(symbols, dict) and symbols, "$.symbols", "symbols must be a non-empty object")
scored = 0
for symbol, ctx in symbols.items():
sp = f"$.symbols.{symbol}"
_validate_symbol_key(str(symbol), sp)
_expect(_is_obj(ctx), sp, "symbol result must be an object")
if "error" in ctx:
continue
score = ctx.get("score")
_expect(_is_obj(score), f"{sp}.score", "non-error symbol requires score")
_expect(bool(str(score.get("verdict") or "").strip()), f"{sp}.score.verdict", "verdict is required")
_expect("composite" in score, f"{sp}.score.composite", "composite is required")
_expect(isinstance(score.get("blocking_reasons"), list), f"{sp}.score.blocking_reasons", "blocking_reasons must be a list")
_expect(_is_obj(score.get("trade_plan")), f"{sp}.score.trade_plan", "trade_plan must be an object")
# account overlay 关闭/离线时 scoring 合法地输出 null(与 llm_overlay 惯例一致)
account_overlay = score.get("account_overlay")
_expect(account_overlay is None or _is_obj(account_overlay), f"{sp}.score.account_overlay", "account_overlay must be an object or null")
scored += 1
return {"symbols_count": len(symbols), "scored_symbols_count": scored}
def validate_snapshot(payload: Any) -> dict[str, Any]:
_expect(_is_obj(payload), "$", "snapshot_context must be an object")
status = _status(payload, "$")
if status != "ok":
return {"symbols_count": 0}
symbols = payload.get("symbols")
_expect(isinstance(symbols, dict), "$.symbols", "symbols must be an object")
for symbol, ctx in symbols.items():
sp = f"$.symbols.{symbol}"
_validate_symbol_key(str(symbol), sp)
_expect(_is_obj(ctx), sp, "symbol snapshot must be an object")
_expect(
str(ctx.get("symbol") or symbol).strip().upper() == str(symbol).strip().upper(),
f"{sp}.symbol",
"symbol field must match key",
)
for key in ("daily_bar", "quote", "latest_trade", "minute_bar"):
if key in ctx:
_expect(_is_obj(ctx[key]), f"{sp}.{key}", f"{key} must be an object")
return {"symbols_count": len(symbols)}
AI_OPINION_STANCES = {"buy", "watch", "avoid"}
AI_OPINION_CONFIDENCES = {"low", "medium", "high"}
AI_OPINION_AGREEMENTS = {"agree", "diverge", "not_applicable"}
def validate_ai_opinion(payload: Any) -> dict[str, Any]:
_expect(_is_obj(payload), "$", "ai_opinion must be an object")
status = _status(payload, "$")
if status != "ok":
return {"symbols_count": 0}
symbols = payload.get("symbols")
_expect(isinstance(symbols, dict), "$.symbols", "symbols must be an object")
opined = 0
for symbol, ctx in symbols.items():
sp = f"$.symbols.{symbol}"
_validate_symbol_key(str(symbol), sp)
_expect(_is_obj(ctx), sp, "symbol opinion must be an object")
ctx_status = _status(ctx, sp)
if ctx_status != "ok":
continue
_expect(
str(ctx.get("stance") or "").lower() in AI_OPINION_STANCES,
f"{sp}.stance",
"stance must be buy, watch, or avoid",
)
_expect(
str(ctx.get("confidence") or "").lower() in AI_OPINION_CONFIDENCES,
f"{sp}.confidence",
"confidence must be low, medium, or high",
)
_expect(bool(str(ctx.get("view") or "").strip()), f"{sp}.view", "view is required")
_expect(
str(ctx.get("agreement") or "").lower() in AI_OPINION_AGREEMENTS,
f"{sp}.agreement",
"agreement must be agree, diverge, or not_applicable",
)
if ctx.get("key_risks") is not None:
_expect(isinstance(ctx["key_risks"], list), f"{sp}.key_risks", "key_risks must be a list")
opined += 1
return {"symbols_count": len(symbols), "opined_symbols_count": opined}
VALIDATORS = {
KIND_NEWS: validate_news_context,
KIND_ACCOUNT: validate_account_context,
KIND_FINNHUB: validate_finnhub_context,
KIND_BARS: validate_bars,
KIND_SNAPSHOT: validate_snapshot,
KIND_RESULT: validate_result,
KIND_AI_OPINION: validate_ai_opinion,
}
def validate_payload(kind: str, payload: Any) -> dict[str, Any]:
"""Validate an artifact and return a compact summary."""
_expect(kind in VALIDATORS, "$.kind", f"kind must be one of {', '.join(KINDS)}")
if _is_obj(payload) and payload.get("contract_version") is not None:
_expect(
payload["contract_version"] == CONTRACT_VERSION,
"$.contract_version",
f"contract_version must be {CONTRACT_VERSION}",
)
if _is_obj(payload) and payload.get("kind") is not None:
_expect(payload["kind"] == kind, "$.kind", f"kind must be {kind}")
details = VALIDATORS[kind](payload)
version = payload.get("contract_version") if _is_obj(payload) else None
return {
"status": "ok",
"kind": kind,
"contract_version": version or CONTRACT_VERSION,
**details,
}