alphabrief / apps /api /app /graph /compose.py
Abdr007's picture
AlphaBrief — deployed tree
69e310f
Raw
History Blame Contribute Delete
13.3 kB
"""Deterministic brief assembly from verified state.
Used in two places:
* by :class:`~app.core.claude.DeterministicEngine` as the offline writer, and
* by the writer node as the **canonical claim table**: even when Claude writes the
prose, the numbers it is allowed to cite are minted here, straight from
tool-computed metrics.
Every narrative string produced here is digit-free by construction — figures are
emitted only as ``{{cN}}`` claim references.
"""
from __future__ import annotations
from typing import Any, Literal
from app.models.brief import (
Brief,
KeyMove,
NewsAndSentiment,
NumericClaim,
RiskFlag,
SnapshotRow,
WatchItem,
)
from app.models.market import METRIC_UNITS, Fundamentals, Metrics, NewsFeed, RiskEvent, Sentiment
#: Direction of a single-session move.
Direction = Literal["up", "down", "flat"]
#: Metrics minted as claims for each ticker, in snapshot column order.
SNAPSHOT_METRICS: tuple[tuple[str, str], ...] = (
("last_close", "last_close"),
("change_1d_pct", "change_1d"),
("return_30d_pct", "return_30d"),
("volatility_annualised_pct", "volatility"),
("max_drawdown_pct", "max_drawdown"),
("pe_ratio", "pe_ratio"),
)
#: Above this annualised volatility a ticker earns an automatic watch item.
HIGH_VOLATILITY_PCT = 40.0
#: Below this 30-day return a ticker is called out as a laggard.
WEAK_RETURN_PCT = -5.0
STRONG_RETURN_PCT = 5.0
class ClaimMinter:
"""Allocates stable ``c1..cN`` ids for tool-computed values."""
def __init__(self) -> None:
self._claims: list[NumericClaim] = []
self._index: dict[tuple[str, str], str] = {}
def mint(self, ticker: str, metric: str, value: float | None) -> str | None:
"""Register a claim and return its reference id, or ``None`` if absent."""
if value is None:
return None
key = (ticker, metric)
if key in self._index:
return self._index[key]
claim_id = f"c{len(self._claims) + 1}"
self._claims.append(
NumericClaim(
claim_id=claim_id,
ticker=ticker,
metric=metric,
value=float(value),
unit=METRIC_UNITS[metric],
)
)
self._index[key] = claim_id
return claim_id
def ref(self, ticker: str, metric: str) -> str | None:
return self._index.get((ticker, metric))
@property
def claims(self) -> list[NumericClaim]:
return list(self._claims)
def _company_name(fundamentals: Fundamentals | None, ticker: str) -> str:
if fundamentals and fundamentals.ok and fundamentals.name:
return fundamentals.name
return ticker
def compose_brief(
*,
session_date: str,
tickers: list[str],
metrics: dict[str, Metrics],
fundamentals: dict[str, Fundamentals],
sentiment: dict[str, Sentiment],
news: dict[str, NewsFeed],
risk_events: list[RiskEvent],
data_gaps: list[str],
) -> Brief:
"""Assemble a fully-verified brief. Performs no arithmetic beyond comparisons."""
minter = ClaimMinter()
usable = [t for t in tickers if (m := metrics.get(t)) is not None and m.ok]
partial = len(usable) < len(tickers)
# ----------------------------------------------------------- snapshot ---
snapshot: list[SnapshotRow] = []
for ticker in usable:
metric = metrics[ticker]
refs: dict[str, str | None] = {}
for metric_name, column in SNAPSHOT_METRICS:
refs[column] = minter.mint(ticker, metric_name, metric.value_of(metric_name))
senti = sentiment.get(ticker)
refs["sentiment"] = minter.mint(ticker, "sentiment_score", senti.score) if senti else None
filled = sum(1 for value in refs.values() if value)
snapshot.append(
SnapshotRow(
ticker=ticker,
company=_company_name(fundamentals.get(ticker), ticker),
status="ok" if filled >= len(refs) - 1 else "partial",
last_close=refs["last_close"],
change_1d=refs["change_1d"],
return_30d=refs["return_30d"],
volatility=refs["volatility"],
max_drawdown=refs["max_drawdown"],
pe_ratio=refs["pe_ratio"],
sentiment=refs["sentiment"],
note=None
if filled >= len(refs) - 1
else "Some fields unavailable from the provider.",
)
)
# ---------------------------------------------------------- key moves ---
ranked = sorted(
(t for t in usable if metrics[t].change_1d_pct is not None),
key=lambda t: abs(metrics[t].change_1d_pct or 0.0),
reverse=True,
)
key_moves: list[KeyMove] = []
for ticker in ranked[:3]:
metric = metrics[ticker]
change = metric.change_1d_pct or 0.0
close_ref = minter.ref(ticker, "last_close")
change_ref = minter.ref(ticker, "change_1d_pct")
return_ref = minter.ref(ticker, "return_30d_pct")
direction: Direction = "up" if change > 0 else ("down" if change < 0 else "flat")
verb = {"up": "advanced", "down": "declined", "flat": "held flat"}[direction]
# Percent-unit claims already render with a trailing '%', so the prose
# must not repeat the unit as a word.
parts = [f"{ticker} {verb} on the session"]
if change_ref:
parts.append(f"moving {{{{{change_ref}}}}}")
if close_ref:
parts.append(f"to close at {{{{{close_ref}}}}}")
narrative = ", ".join(parts) + "."
if return_ref:
narrative += (
f" Over the trailing thirty-day window it has returned {{{{{return_ref}}}}}."
)
key_moves.append(KeyMove(ticker=ticker, narrative=narrative, direction=direction))
# ------------------------------------------------------ news read-through ---
news_blocks: list[NewsAndSentiment] = []
for ticker in tickers:
senti = sentiment.get(ticker)
feed = news.get(ticker)
if senti is None and feed is None:
continue
top = feed.items[0] if feed and feed.items else None
if senti is None:
summary = "No sentiment could be derived for this ticker."
elif feed is not None and feed.is_empty:
summary = (
"No recent headlines were published for this ticker, so sentiment is "
"reported as neutral."
)
else:
summary = (
f"Coverage reads {senti.label}. {senti.reasoning} "
f"Composite sentiment is recorded as "
f"{{{{{minter.ref(ticker, 'sentiment_score') or ''}}}}}."
if minter.ref(ticker, "sentiment_score")
else f"Coverage reads {senti.label}. {senti.reasoning}"
)
news_blocks.append(
NewsAndSentiment(
ticker=ticker,
summary=_strip_digits_fallback(summary),
sentiment=minter.ref(ticker, "sentiment_score"),
top_headline=top.title if top else None,
headline_source=top.source if top else None,
)
)
# -------------------------------------------------------- risk flags ---
risk_flags = [
RiskFlag(
ticker=event.ticker,
category=event.category,
evidence=event.headline,
assessment=(
f"Flagged from live coverage as a {event.category} risk; confirm before acting."
),
)
for event in risk_events[:8]
]
# ------------------------------------------------------- watch items ---
watch_items: list[WatchItem] = []
for ticker in usable:
metric = metrics[ticker]
vol = metric.volatility_annualised_pct
ret = metric.return_30d_pct
if vol is not None and vol >= HIGH_VOLATILITY_PCT:
ref = minter.ref(ticker, "volatility_annualised_pct")
watch_items.append(
WatchItem(
ticker=ticker,
item=(
f"Elevated annualised volatility at {{{{{ref}}}}} percent — "
f"size positions accordingly."
if ref
else "Elevated annualised volatility — size positions accordingly."
),
)
)
if ret is not None and ret <= WEAK_RETURN_PCT:
ref = minter.ref(ticker, "return_30d_pct")
watch_items.append(
WatchItem(
ticker=ticker,
item=(
f"Trailing thirty-day return of {{{{{ref}}}}} percent marks this as "
f"a laggard to review."
if ref
else "Trailing thirty-day return marks this as a laggard to review."
),
)
)
if data_gaps:
watch_items.append(
WatchItem(
ticker=None, item="Resolve the outstanding data gaps before the next session."
)
)
if not watch_items:
watch_items.append(
WatchItem(
ticker=None, item="No positions breached the volatility or drawdown thresholds."
)
)
# ---------------------------------------------------------- narrative ---
movers_up = [t for t in usable if (metrics[t].change_1d_pct or 0.0) > 0]
headline = _headline_for(usable, movers_up, partial)
summary = _summary_for(usable, movers_up, risk_flags, partial)
return Brief(
generated_for=session_date,
watchlist=list(tickers),
headline=headline,
executive_summary=summary,
snapshot=snapshot,
key_moves=key_moves,
news_and_sentiment=news_blocks,
risk_flags=risk_flags,
watch_items=watch_items[:6],
data_gaps=list(data_gaps),
claims=minter.claims,
partial=partial,
)
def _headline_for(usable: list[str], movers_up: list[str], partial: bool) -> str:
if not usable:
return "Morning brief unavailable — no ticker returned usable market data"
if len(movers_up) * 2 > len(usable):
tone = "Watchlist leans higher into the session"
elif not movers_up:
tone = "Watchlist broadly lower into the session"
else:
tone = "Watchlist mixed into the session"
return f"{tone}{' (partial coverage)' if partial else ''}"
def _summary_for(
usable: list[str], movers_up: list[str], risk_flags: list[RiskFlag], partial: bool
) -> str:
if not usable:
return (
"No ticker on the watchlist returned usable market data, so no figures are "
"reported. The underlying provider errors are listed under data gaps."
)
advancing = ", ".join(movers_up) if movers_up else "none"
declining = ", ".join(t for t in usable if t not in movers_up) or "none"
parts = [
f"Advancing: {advancing}. Declining: {declining}.",
"Every figure in this brief was computed by the tool layer and independently "
"recomputed by the verifier before delivery.",
]
if risk_flags:
flagged = ", ".join(sorted({flag.ticker for flag in risk_flags}))
parts.insert(1, f"Risk headlines were flagged for: {flagged}.")
if partial:
parts.insert(
1,
"Coverage is partial — at least one ticker failed to return market data and is "
"excluded from the snapshot.",
)
return " ".join(parts)
def _strip_digits_fallback(text: str) -> str:
"""Last-resort guard: replace stray numerals in generated prose.
Provider reasoning strings are interpolated into summaries, and a lexicon
explanation could in principle contain a numeral. Rather than fail schema
validation, spell the sentence without it — the number is always available as
a verified claim elsewhere in the brief.
"""
from app.models.brief import strip_claim_refs
if not any(ch.isdigit() for ch in strip_claim_refs(text)):
return text
out: list[str] = []
index = 0
while index < len(text):
if text.startswith("{{", index):
end = text.find("}}", index)
if end != -1:
out.append(text[index : end + 2])
index = end + 2
continue
char = text[index]
out.append("" if char.isdigit() else char)
index += 1
cleaned = "".join(out)
while " " in cleaned:
cleaned = cleaned.replace(" ", " ")
return cleaned.strip()
def compose_brief_payload(context: dict[str, Any]) -> dict[str, Any]:
"""Adapter used by the deterministic engine's ``emit_brief`` tool call."""
brief = compose_brief(
session_date=context["session_date"],
tickers=list(context["tickers"]),
metrics=context["metrics"],
fundamentals=context["fundamentals"],
sentiment=context["sentiment"],
news=context["news"],
risk_events=list(context.get("risk_events", [])),
data_gaps=list(context.get("data_gaps", [])),
)
return brief.model_dump()