Spaces:
Running
Running
File size: 13,342 Bytes
69e310f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 | """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()
|