Financial RAG Accuracy Comes From Tools, Not More Retrieval: A Calculator, A Token Budget And Evidence Scoring, In Code
Between 21 September and 6 October 2026, four arXiv papers measured what makes financial RAG accurate, and AlphaSense launched SuperAnalyst, an always-on research agent that keeps updating its work after the first answer. The evidence points one way: executable tools beat extra documents, top-1 retrieval with budgeted escalation beats five passages for every question, and scoring the evidence exposes correct answers with missing support. This guide builds all three in Python, plus a refresher that re-runs only the claims a new filing touches.
AlchmAI Engineering25 min read
+19.5 points
Gain from executable tools alone in FinSkillBench (arXiv, 2 October 2026), against +5.6 points from procedural documents alone
46.6%
Fewer tokens than one-pass top-5 retrieval in a financial RAG study where 150 FinanceBench questions start at top-1 and escalate by gain per token under a 10% budget (arXiv, 6 October 2026)
17.48%
Of correct-answer attempts in the FinFIRST benchmark that lacked a complete, verifiable evidence chain (arXiv, 21 September 2026)
Below 50%
Where general text-to-SQL methods fall on production financial databases that store concepts as opaque integer keys (FLINT, arXiv, 2 October 2026)
On 6 October 2026 AlphaSense launched SuperAnalyst, which it calls a next-generation AI agent for financial and strategic workflows, and described it as always-on: it plans work, reconciles evidence, performs calculations, writes and runs code, and keeps monitoring the assumptions behind its outputs. On 7 October the company said SuperAnalyst is available to all customers. In the same fortnight, four arXiv papers measured what actually makes financial RAG (retrieval-augmented generation over filings, transcripts and market data) get the number right. Their answer is not more retrieval. Executable tools beat extra documents, a top-1 first pass with budgeted escalation beats five passages for every question, and scoring the evidence exposes correct answers that rest on missing support.
For a quant team, a trading education platform or a broker publishing research, the expensive failure is rarely a missing passage. It is a growth rate worked out in the model's head from two correctly retrieved figures, a citation to last year's annual report, or a lesson that still quotes a figure the company restated last week. AlphaSense, which says its platform covers over 500 million premium business documents and 300,000 proprietary expert interviews, built its release around the same ingredients: tools that compute, evidence tied to every data point, and outputs that update when conditions change. Those are the company's claims about its own product. The pattern itself is buildable in-house, and the new research says which parts of it are worth the effort.
This guide builds three pieces in Python. First, a question-answering service in which the model writes formulas and a calculator evaluates them against cited evidence, run alongside a documents-only baseline. Second, a retriever that starts every question with one page and spends a fixed token budget on deeper context where the predicted gain per token is highest. Third, an evaluation harness that scores evidence as well as the final figure, with an always-on refresher that re-runs only the claims a new filing touches and puts every material change in front of a person.
1. Why Do Calculator Tools Beat More Documents In Financial RAG?
FinSkillBench, published on arXiv on 2 October 2026 by Jermyn Zhen Yong Bek, Zhuang Qiang Bok and Zhongtian Sun, is the cleanest test we have seen of what helps a financial agent. It contains 2,603 point-in-time episodes across 12 subtasks in portfolio construction, risk management and fundamental analysis, each with hidden ground truth and a deterministic verifier, and the authors executed 17,820 episodes across 9 models. In the paired analysis across 8 models, curated skill packages, meaning a written procedure plus small validated Python entry points, raised mean scores by 16.2 points, from 0.366 to 0.528. Then the authors took the package apart. Procedural documents alone added 5.6 points. Executable tools alone added 19.5 points. The combination was subadditive: in the paper's own decomposition table, tools alone scored 0.561 overall against 0.528 for the full package.
Two further findings matter to anyone building financial RAG. Skills the model wrote for itself inside an episode added only 0.5 points while consuming more tokens and turns, so asking a model to plan its method first is no substitute for a tested function. And the authors explain why tools win. In piloting, they found that models 'reliably invent the shape of a computation but fail to pass large numeric structures through tool calls at full precision'. A covariance matrix of 900 numbers, emitted by a model and parsed back into an optimiser, differed from the source in the last few digits, which was enough to change the optimum. The benchmark therefore injects full-precision values on the server instead of passing them through JSON the model wrote.
Structured data teaches the same lesson. The FLINT paper (arXiv, 2 October 2026) reports that general text-to-SQL methods which do well on the Spider and BIRD benchmarks fall below 50% on production financial databases, where concepts are stored as opaque integer keys and even simple questions need several joins. FLINT's answer is again a tool rather than more context: a lookup agent that resolves natural-language concepts to reference-table constraints, query templates retrieved from an expert-authored bank, and schema linking that follows foreign-key chains. The authors say it is deployed in production as part of a financial data retrieval service.
So the service below makes the calculator mandatory, not optional. The model sees evidence spans, each with an ID, and writes a formula whose variables are bound to span IDs. The server substitutes the values parsed at ingestion, evaluates the formula in Decimal arithmetic through a whitelist of syntax-tree nodes, and refuses any final figure that is neither quoted from a cited span nor produced by a recorded calculation. The same function runs in a documents-only mode, which is your baseline: identical evidence and prompt, no calculator, and any derived figure marked as the model's own arithmetic. It calls Claude Opus 5.5 through the anthropic Python SDK, with strict tool schemas and Anthropic's server-side fallback for declined requests.
"""Financial QA with a calculator tool, against a documents-only baseline.
Python 3.12+, anthropic==1.13.0. The SDK reads ANTHROPIC_API_KEY from the environment.
The model picks evidence spans and writes the formula. The server binds each variable
to a span's parsed value at full precision, so no figure is ever re-typed by the model
on its way into the arithmetic (the failure FinSkillBench saw with large numeric inputs).
"""
from __future__ import annotations
import ast
import json
import operator
import os
from dataclasses import dataclass, field
from decimal import Decimal, getcontext
from typing import Literal
import anthropic
getcontext().prec = 34
MODEL = os.environ.get("FINQA_MODEL", "claude-opus-5-5")
client = anthropic.Anthropic()
Mode = Literal["tools", "documents_only"]
@dataclass(frozen=True)
class Span:
span_id: str # e.g. "ACME-AR-FY2025:p61:t4:r7"
doc_id: str # the filing, RNS or transcript it came from
text: str # the row or sentence exactly as the model sees it
value: Decimal | None = None # parsed once at ingestion, never by the model
unit: str | None = None # "GBP m", "%", "pence"
period: str | None = None # "FY2025", "H1 2026"
@dataclass
class Answer:
question: str
mode: Mode
text: str = ""
value: Decimal | None = None
unit: str | None = None
cited_span_ids: list[str] = field(default_factory=list)
calculation: dict | None = None
computed_by: Literal["calculator", "quoted", "model", "none"] = "none"
input_tokens: int = 0
output_tokens: int = 0
served_by: str = MODEL
# The calculator: an AST whitelist over Decimal. No names except bound spans, no attributes.
_BIN = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow}
_FUNCS = {"abs": abs, "min": min, "max": max}
def safe_eval(expr: str, env: dict[str, Decimal]) -> Decimal:
if len(expr) > 300:
raise ValueError("expression longer than 300 characters")
def ev(node: ast.AST) -> Decimal:
match node:
case ast.Expression(body=body):
return ev(body)
case ast.Constant(value=v) if isinstance(v, (int, float)) and not isinstance(v, bool):
return Decimal(str(v))
case ast.Name(id=name):
if name not in env:
raise ValueError(f"unbound variable {name!r}: bind it to a span_id")
return env[name]
case ast.UnaryOp(op=ast.USub(), operand=operand):
return -ev(operand)
case ast.BinOp(left=left, op=op, right=right) if type(op) in _BIN:
lv, rv = ev(left), ev(right)
if isinstance(op, ast.Pow) and (rv != rv.to_integral_value() or abs(rv) > 12):
raise ValueError("exponent must be a whole number between -12 and 12")
return _BIN[type(op)](lv, rv)
case ast.Call(func=ast.Name(id=fn), args=args, keywords=[]) if fn in _FUNCS:
return _FUNCS[fn](*(ev(a) for a in args))
raise ValueError(f"operation not allowed: {type(node).__name__}")
return ev(ast.parse(expr, mode="eval"))
NULLABLE = {"anyOf": [{"type": "string"}, {"type": "null"}]}
CALCULATE = {
"name": "calculate",
"description": ("Evaluate arithmetic such as (rev_25 - rev_24) / rev_24 * 100. Bind every "
"variable to a span_id; the server substitutes that span's exact value."),
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"expression": {"type": "string"},
"bindings": {"type": "array", "items": {
"type": "object",
"properties": {"name": {"type": "string"}, "span_id": {"type": "string"}},
"required": ["name", "span_id"], "additionalProperties": False}},
"result_unit": {"type": "string"},
},
"required": ["expression", "bindings", "result_unit"],
"additionalProperties": False,
},
}
SUBMIT = {
"name": "submit_answer",
"description": ("Submit the final answer once. cited_span_ids lists every span the answer "
"relies on. value is the headline figure as a plain decimal string, or null."),
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"answer_text": {"type": "string"},
"value": NULLABLE,
"unit": NULLABLE,
"cited_span_ids": {"type": "array", "items": {"type": "string"}},
"calculation_id": NULLABLE,
},
"required": ["answer_text", "value", "unit", "cited_span_ids", "calculation_id"],
"additionalProperties": False,
},
}
SYSTEM = ("You answer questions about company filings and call transcripts using only the "
"evidence spans supplied, each prefixed with its [span_id]. Cite every span you rely "
"on. {rule} Finish by calling submit_answer. If the evidence does not contain the "
"answer, submit value null and say what is missing. Describe figures only; never "
"give investment advice or opinions on securities.")
RULES = {
"tools": "Never do arithmetic yourself: every derived figure must come from calculate.",
"documents_only": "You have no calculator; show your working in answer_text.",
}
def build_request(question: str, spans: list[Span], mode: Mode) -> dict:
"""Shared by answer_question and by the token budgeter, so counts match real calls."""
evidence = "\n".join(f"[{s.span_id}] ({s.doc_id}, {s.period or 'n/a'}) {s.text}" for s in spans)
return {
"system": SYSTEM.format(rule=RULES[mode]),
"tools": [CALCULATE, SUBMIT] if mode == "tools" else [SUBMIT],
"messages": [{"role": "user", "content": f"Evidence:\n{evidence}\n\nQuestion: {question}"}],
}
def _run_calculation(inp: dict, by_id: dict[str, Span], calcs: dict[str, dict]) -> str:
env, units = {}, set()
for b in inp["bindings"]:
span = by_id.get(b["span_id"])
if span is None or span.value is None:
raise ValueError(f"{b['span_id']} is not a numeric span in the evidence")
env[b["name"]] = span.value
units.add(span.unit)
tree = ast.parse(inp["expression"], mode="eval")
adds = any(isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Add, ast.Sub)) for n in ast.walk(tree))
if adds and len(units) > 1:
raise ValueError(f"adding or subtracting mixed units {sorted(map(str, units))}: convert first")
result = safe_eval(inp["expression"], env)
calc_id = f"calc-{len(calcs) + 1}"
calcs[calc_id] = {"id": calc_id, "expression": inp["expression"], "result": str(result),
"unit": inp["result_unit"],
"bindings": {b["name"]: b["span_id"] for b in inp["bindings"]}}
return json.dumps({"calculation_id": calc_id, "result": str(result), "unit": inp["result_unit"]})
def _accept(inp: dict, by_id: dict[str, Span], calcs: dict[str, dict], ans: Answer) -> None:
cited = inp["cited_span_ids"]
if unknown := [s for s in cited if s not in by_id]:
raise ValueError(f"cited spans not in the evidence: {unknown}")
value = Decimal(inp["value"]) if inp["value"] is not None else None
if value is not None and not value.is_finite():
raise ValueError("value must be a finite decimal")
if value is not None and not cited:
raise ValueError("a figure with no cited evidence is not an answer")
calc = calcs.get(inp["calculation_id"] or "")
if inp["calculation_id"] and calc is None:
raise ValueError(f"unknown calculation_id {inp['calculation_id']}")
if value is None:
computed_by = "none"
elif calc is not None:
if not set(calc["bindings"].values()) <= set(cited):
raise ValueError("cite every span the calculation used")
step = Decimal(1).scaleb(value.as_tuple().exponent) # the precision the model reported
if Decimal(calc["result"]).quantize(step) != value:
raise ValueError(f"value {value} does not match {calc['id']} = {calc['result']}")
computed_by = "calculator"
elif any(by_id[s].value == value for s in cited):
computed_by = "quoted"
elif ans.mode == "tools":
raise ValueError("value is neither quoted from a cited span nor produced by calculate")
else:
computed_by = "model" # baseline: unverified arithmetic
ans.text, ans.value, ans.unit = inp["answer_text"], value, inp["unit"]
ans.cited_span_ids, ans.calculation, ans.computed_by = cited, calc, computed_by
def answer_question(question: str, spans: list[Span], mode: Mode = "tools", max_turns: int = 6) -> Answer:
by_id = {s.span_id: s for s in spans}
calcs: dict[str, dict] = {}
ans = Answer(question=question, mode=mode)
req = build_request(question, spans, mode)
messages = req["messages"]
for _ in range(max_turns):
resp = client.beta.messages.create(
model=MODEL, max_tokens=16000, system=req["system"], tools=req["tools"],
messages=messages, output_config={"effort": "medium"},
betas=["server-side-fallback-2026-07-01"], fallbacks="default",
)
ans.input_tokens += resp.usage.input_tokens
ans.output_tokens += resp.usage.output_tokens
ans.served_by = resp.model
if resp.stop_reason == "refusal":
ans.text = "declined by the model; route to a person"
return ans
messages.append({"role": "assistant", "content": resp.content})
tool_uses = [b for b in resp.content if b.type == "tool_use"]
if not tool_uses:
messages.append({"role": "user", "content": "Finish by calling submit_answer."})
continue
results = []
for tu in tool_uses:
try:
if tu.name == "calculate":
results.append({"type": "tool_result", "tool_use_id": tu.id,
"content": _run_calculation(tu.input, by_id, calcs)})
else:
_accept(tu.input, by_id, calcs, ans)
return ans
except (ValueError, ArithmeticError, SyntaxError) as exc:
results.append({"type": "tool_result", "tool_use_id": tu.id,
"content": f"rejected: {exc}", "is_error": True})
messages.append({"role": "user", "content": results})
ans.text = f"no accepted answer within {max_turns} turns"
return ans- Values are parsed once, at ingestion, by code you can test. The model never types a figure into the arithmetic, which removes the precision failure FinSkillBench describes instead of hoping a larger model avoids it.
- The mixed-unit guard refuses to add pounds in millions to pounds in billions. Refusals return to the model as tool errors, so it can convert or pick another span, and the turn cap stops a loop.
- Run both modes on the same questions before anyone argues about retrieval budgets. FinSkillBench's result is an average over 12 subtasks, and the paper notes that documentation matters more where procedure or output format is the bottleneck, so measure your own mix.
2. How Much Context Does A Financial Question Actually Need?
The second paper, by Junru Zhu, Yixin Yang, Xiaoqing Ding and Ruoyu Qi (arXiv, 6 October 2026), asks the question every team running financial RAG at volume should ask: which questions deserve more context? On all 150 public FinanceBench questions, drawn from 84 source documents, answering from the single best page found by TF-IDF retrieval (term frequency weighted by inverse document frequency) reached 46.7% accuracy at 1,091 tokens per question. One pass with the top five pages reached 54.7% at 2,508 tokens per question.
The authors' policy answers every question from the top page first, then predicts two things about an optional top-5 call: how much it would improve the answer and how many tokens it would cost. With a budget equal to 10% of the cost of escalating the whole batch, ranking candidates by predicted gain per token reached 49.3% accuracy at 1,340 tokens per question. That is 46.6% fewer total tokens than one-pass top-5 retrieval, and it beat ranking by predicted gain alone (adjudication quality 0.524 against 0.490). Cost was easy to predict, with an R-squared of 0.93. Benefit was not: the AUROC (area under the receiver operating characteristic curve, where 0.5 is a coin toss) was 0.60.
Read the tables honestly and they say two things. First, a budgeted policy does not match five pages for everyone: 49.3% is well short of 54.7%. Second, the gap is a prediction problem, not a retrieval problem. The paper's oracle policy, an upper bound that knows each escalation's realised gain and cost in advance, reached 56.0% accuracy at the same 1,340 tokens per question, above one-pass top-5 at a little over half its token cost. The authors also say the advantage does not hold evenly at larger budgets, and their setup supplied the source document for each question, so treat the numbers as a direction to test on your own data rather than a forecast.
That shapes the code. Cost prediction uses Anthropic's token counting endpoint, which is free to use, rate limited separately from message creation, and returns an estimate; the predicted cost of escalating is the observed top-1 spend scaled by the ratio of the two prompt sizes. Benefit prediction is a deliberately plain logistic regression over signals available after the top-1 answer, trained on your own adjudicated history, and it prints its held-out AUROC every time it is fitted so nobody forgets how weak it is. Every question's route and tokens are logged, which is also how you collect labels for the next version.
"""Top-1 first, then escalate to top-5 by predicted gain per token under a batch budget.
Python 3.12+, anthropic==1.13.0, scikit-learn==1.9.1, numpy 2.x.
Same shape as arXiv 2610.07760: everyone gets the cheap route; the budget, set as a
fraction of what escalating the whole batch would cost, buys deeper context only where
predicted gain per additional token is highest. Every question's tokens are logged.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from fin_qa.service import MODEL, Answer, Span, answer_question, build_request, client
COMPARATIVE = re.compile(r"\b(change|growth|increase|decrease|ratio|margin|versus|compared|between|per share)\b", re.I)
PERIODS = re.compile(r"\b(?:FY|H[12] |Q[1-4] )?20\d\d\b")
class PageIndex:
"""TF-IDF page retrieval, the cheap first route used in the paper."""
def __init__(self, pages: list[Span]):
self.pages = pages
self.vectoriser = TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True)
self.matrix = self.vectoriser.fit_transform([p.text for p in pages])
def search(self, question: str, k: int) -> tuple[list[Span], np.ndarray]:
scores = (self.matrix @ self.vectoriser.transform([question]).T).toarray().ravel()
order = np.argsort(-scores)[:k]
return [self.pages[i] for i in order], scores[order]
def features(question: str, top1: Answer, scores: np.ndarray) -> list[float]:
"""Signals available after the top-1 answer. Expect them to be weak: the paper's AUROC was 0.60."""
return [
float(scores[0]),
float(scores[0] - scores[1]) if len(scores) > 1 else float(scores[0]),
1.0 if top1.value is None else 0.0, # top-1 abstained
float(len(set(PERIODS.findall(question)))), # multi-period questions need more pages
1.0 if COMPARATIVE.search(question) else 0.0,
1.0 if top1.computed_by == "calculator" else 0.0,
]
def fit_gain_model(history_jsonl: Path) -> LogisticRegression:
"""Rows from past runs: features plus 'helped' (did top-5 beat top-1 under adjudication?)."""
rows = [json.loads(line) for line in history_jsonl.read_text().splitlines() if line.strip()]
X = np.array([r["features"] for r in rows])
y = np.array([int(r["helped"]) for r in rows])
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, stratify=y, random_state=7)
model = LogisticRegression(max_iter=1000, class_weight="balanced").fit(X_tr, y_tr)
auroc = roc_auc_score(y_te, model.predict_proba(X_te)[:, 1])
print(f"gain model held-out AUROC {auroc:.2f} (0.50 is a coin toss)")
return model.fit(X, y)
def prompt_tokens(question: str, spans: list[Span]) -> int:
"""Token counting is a separate, free, rate-limited endpoint; the count is an estimate."""
req = build_request(question, spans, "tools")
return client.messages.count_tokens(model=MODEL, **req).input_tokens
@dataclass
class Candidate:
qid: str
question: str
top1: Answer
p_gain: float
predicted_cost: int # predicted tokens for the extra top-5 call
features: list[float]
@property
def gain_per_token(self) -> float:
return self.p_gain / max(self.predicted_cost, 1)
def run_batch(questions: list[dict], index: PageIndex, gain_model: LogisticRegression,
budget_fraction: float = 0.10, log_path: Path = Path("logs/qa_tokens.jsonl")) -> dict:
cands: list[Candidate] = []
for q in questions:
pages, scores = index.search(q["question"], 5)
top1 = answer_question(q["question"], pages[:1])
# Cost is the predictable half: scale the observed top-1 spend by the prompt-size ratio.
ratio = prompt_tokens(q["question"], pages) / max(prompt_tokens(q["question"], pages[:1]), 1)
x = features(q["question"], top1, scores)
cands.append(Candidate(
qid=q["id"], question=q["question"], top1=top1, features=x,
p_gain=float(gain_model.predict_proba([x])[0, 1]),
predicted_cost=int((top1.input_tokens + top1.output_tokens) * ratio)))
# Budget = fraction of the predicted cost of escalating everyone; greedy by gain per token.
budget = budget_fraction * sum(c.predicted_cost for c in cands)
chosen, committed = set(), 0
for c in sorted(cands, key=lambda c: c.gain_per_token, reverse=True):
if committed + c.predicted_cost <= budget:
chosen.add(c.qid)
committed += c.predicted_cost
log_path.parent.mkdir(parents=True, exist_ok=True)
total = 0
with log_path.open("a") as log:
for c in cands:
final, extra = c.top1, 0
if c.qid in chosen:
pages, _ = index.search(c.question, 5)
final = answer_question(c.question, pages)
extra = final.input_tokens + final.output_tokens
top1_tokens = c.top1.input_tokens + c.top1.output_tokens
total += top1_tokens + extra
log.write(json.dumps({
"qid": c.qid, "route": "top5" if c.qid in chosen else "top1",
"top1_tokens": top1_tokens, "escalation_tokens": extra,
"predicted_escalation_tokens": c.predicted_cost, "p_gain": round(c.p_gain, 4),
"gain_per_1k_tokens": round(c.gain_per_token * 1000, 5), "features": c.features,
"value": None if final.value is None else str(final.value), "unit": final.unit,
"cited": final.cited_span_ids, "computed_by": final.computed_by,
"served_by": final.served_by,
}) + "\n")
n = len(cands)
return {"questions": n, "escalated": len(chosen), "budget_tokens": int(budget),
"tokens_per_question": round(total / max(n, 1)),
"predicted_top5_for_all_per_question": round(sum(c.predicted_cost for c in cands) / max(n, 1))}- Set the budget as a fraction of the cost of escalating everyone, as the paper does, so it scales with batch size and prompt length instead of needing a new absolute number every quarter.
- The cheapest improvement is usually in the gain model's labels, not its algorithm. Log top-1 and top-5 answers side by side for a sample, have an analyst mark which was better, and refit monthly.
- Test top-2 as a middle route. In the same paper it reached 52.0% accuracy at 1,719 tokens per question, against 54.7% at 2,508 for top-5, which makes it a sensible escalation step before the full five pages.
3. How Do You Score Evidence And Keep Answers Current When A New Filing Lands?
FinFIRST (arXiv, 21 September 2026) describes itself as 'the first financial benchmark to jointly evaluate answers and supporting evidence through atomic rubrics'. Its 123 expert-authored tasks drew on contributions from over 50 finance experts and a registry of 138 financial sources. Each task carries a reference package (the answer, supporting sources, key definitions and data versions, calculations, valid alternatives and numerical tolerances) broken into atomic criteria across three dimensions: raw-information acquisition, source verification, and computation and answer formation. Fifteen model configurations ran with the same tools: web search, web page fetching and Python execution.
Claude-Opus-5 had the highest atomic score, 87.59%, yet its strict pass rate, where every criterion on a task must pass, was 69.11%. GPT-5.6-Sol had the highest strict pass rate, at 71.54%. Computation and answer formation lagged raw-information acquisition across all evaluated systems, even though every system had Python available, and 17.48% of correct-answer attempts lacked a complete, verifiable evidence chain. Two conclusions follow. Having Python in the toolset did not close the computation gap, which suggests that a calculator which is merely available is not used reliably; that is why step 1 refuses uncomputed figures. And roughly one correct answer in six arrived without a complete chain of evidence behind it, which a final-answer metric never reveals.
The other half of the problem is time. AlphaSense's 6 October release says SuperAnalyst 'keeps working after the initial analysis is complete', monitoring the assumptions behind a thesis or portfolio, identifying material changes and updating the work product without a new prompt. It says the agent's Context Graph turns unstructured content into structured knowledge 'with every data point tied to its source evidence', and that outputs from dashboards to Excel models can update automatically as conditions change. Its 7 October governance release adds that the company maintains 'zero training and zero data retention across every model and tool'. We have not tested any of this. The engineering pattern underneath is plain, though: if every published figure records the documents it depends on, a new filing, restatement or transcript only needs to re-run the claims that depend on it.
The harness below scores each answer as atomic items in the four places it can fail, reports atomic score, strict pass and the share of correct answers without full support, and compares the calculator mode with the documents-only baseline from step 1. The refresher subscribes to new documents, finds dependent claims, re-runs them under a re-run cap and a token budget, diffs old and new values, and opens a review for any material change. It never republishes on its own. A named reviewer approves the exact diff, every step goes into a hash-chained audit log, and an environment-variable kill switch stops it.
"""Evidence-level scoring, plus an always-on refresher that re-runs only dependent claims.
Python 3.12+, standard library only (uses fin_qa.service for Span and Answer).
Scoring follows FinFIRST's split: information acquisition, source verification and
computation, scored as atomic items next to the final answer, so a right number on
missing evidence is counted as what it is. The refresher never publishes on its own.
"""
from __future__ import annotations
import hashlib
import json
import os
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path
from fin_qa.service import Answer, Span
# ---------- 1. Evidence-level evaluation ----------
@dataclass(frozen=True)
class Reference:
task_id: str
question: str
required_span_ids: frozenset[str] # what an analyst would have to find
authoritative_docs: frozenset[str] # e.g. the annual report, not a news summary of it
periods: frozenset[str] # every period the answer legitimately needs
expected: Decimal
unit: str
tolerance: Decimal # absolute, in the reference unit
def score(ref: Reference, ans: Answer, retrieved: list[Span]) -> dict[str, bool]:
by_id = {s.span_id: s for s in retrieved}
cited = [by_id[i] for i in ans.cited_span_ids if i in by_id]
calc_ok = ans.computed_by == "quoted" or (
ans.computed_by == "calculator" and ans.calculation is not None
and set(ans.calculation["bindings"].values()) <= set(ans.cited_span_ids))
return {
"acquisition.required_spans_retrieved": ref.required_span_ids <= by_id.keys(),
"verification.required_spans_cited": ref.required_span_ids <= set(ans.cited_span_ids),
"verification.sources_authoritative": bool(cited) and all(s.doc_id in ref.authoritative_docs for s in cited),
"verification.periods_aligned": bool(cited) and all(s.period in ref.periods for s in cited),
"computation.reproducible_from_evidence": calc_ok,
"computation.unit_matches": ans.unit == ref.unit,
"final.value_within_tolerance": ans.value is not None and abs(ans.value - ref.expected) <= ref.tolerance,
}
def summarise(results: list[dict[str, bool]]) -> dict[str, float]:
n = len(results) or 1
items = [v for r in results for v in r.values()]
out = {"atomic_score": sum(items) / max(len(items), 1),
"strict_pass": sum(all(r.values()) for r in results) / n}
for dim in ("acquisition", "verification", "computation", "final"):
vals = [v for r in results for k, v in r.items() if k.startswith(dim)]
out[dim] = sum(vals) / max(len(vals), 1)
correct = [r for r in results if r["final.value_within_tolerance"]]
out["correct_but_unsupported"] = sum(not all(r.values()) for r in correct) / max(len(correct), 1)
return out
def compare_modes(refs: list[Reference], retrieve: Callable[[str], list[Span]],
answer: Callable[..., Answer]) -> dict[str, dict]:
report = {}
for mode in ("tools", "documents_only"):
rows, tokens = [], 0
for ref in refs:
spans = retrieve(ref.question)
ans = answer(ref.question, spans, mode=mode)
rows.append(score(ref, ans, spans))
tokens += ans.input_tokens + ans.output_tokens
report[mode] = summarise(rows) | {"tokens_per_question": tokens / max(len(refs), 1)}
return report
# ---------- 2. Always-on refresher ----------
@dataclass
class Claim:
claim_id: str
question: str
entity: str
period_rule: str # "latest" follows new filings; "FY2025" is fixed
answer: Answer
depends_on: set[str] # doc_ids behind the current answer
published_in: list[str] = field(default_factory=list) # lesson or note ids
@dataclass(frozen=True)
class NewDocument:
doc_id: str
entity: str
doc_type: str # "annual report", "RNS", "call transcript"
period: str
supersedes: str | None = None # set for restatements and amended filings
class AuditLog:
"""Append-only JSONL; each record carries the hash of the previous one."""
def __init__(self, path: Path):
self.path = path
path.parent.mkdir(parents=True, exist_ok=True)
lines = path.read_text().splitlines() if path.exists() else []
self.prev = json.loads(lines[-1])["hash"] if lines else "genesis"
def write(self, event: str, **data) -> None:
rec = {"ts": time.time(), "event": event, "prev": self.prev, **data}
rec["hash"] = hashlib.sha256(json.dumps(rec, sort_keys=True, default=str).encode()).hexdigest()
with self.path.open("a") as f:
f.write(json.dumps(rec, default=str) + "\n")
self.prev = rec["hash"]
class Refresher:
def __init__(self, claims: dict[str, Claim], retrieve: Callable[[str, str], list[Span]],
answer: Callable[..., Answer], notify: Callable[[dict], None], audit: AuditLog,
max_reruns: int = 50, token_budget: int = 400_000,
material_rel: Decimal = Decimal("0.005")):
self.claims, self.retrieve, self.answer = claims, retrieve, answer
self.notify, self.audit = notify, audit
self.max_reruns, self.token_budget, self.material_rel = max_reruns, token_budget, material_rel
self.reviews: dict[str, dict] = {}
def dependents(self, doc: NewDocument) -> list[Claim]:
return [c for c in self.claims.values()
if (doc.supersedes is not None and doc.supersedes in c.depends_on)
or (c.entity == doc.entity and c.period_rule == "latest")]
def diff(self, old: Answer, new: Answer) -> dict:
if old.value is None or new.value is None or old.unit != new.unit:
material = (old.value, old.unit) != (new.value, new.unit)
rel = None
else:
rel = abs(new.value - old.value) / max(abs(old.value), Decimal("1e-9"))
material = rel > self.material_rel
return {"old": str(old.value), "new": str(new.value), "unit": new.unit,
"relative_change": None if rel is None else str(rel), "material": material,
"evidence_added": sorted(set(new.cited_span_ids) - set(old.cited_span_ids)),
"evidence_dropped": sorted(set(old.cited_span_ids) - set(new.cited_span_ids))}
def on_new_document(self, doc: NewDocument) -> list[str]:
if os.environ.get("REFRESHER_KILL_SWITCH") == "1":
self.audit.write("halted_by_kill_switch", doc=doc.doc_id)
return []
affected = self.dependents(doc)
if len(affected) > self.max_reruns:
self.audit.write("rerun_cap_hit", doc=doc.doc_id, affected=len(affected), cap=self.max_reruns)
affected = affected[: self.max_reruns]
spent, opened = 0, []
for claim in affected:
if spent >= self.token_budget:
self.audit.write("token_budget_exhausted", doc=doc.doc_id, spent=spent)
break
spans = self.retrieve(claim.question, claim.entity)
new = self.answer(claim.question, spans, mode="tools")
spent += new.input_tokens + new.output_tokens
d = self.diff(claim.answer, new)
self.audit.write("claim_rerun", doc=doc.doc_id, claim=claim.claim_id, diff=d)
docs = {s.doc_id for s in spans if s.span_id in new.cited_span_ids}
if not d["material"]:
claim.depends_on |= docs # same figure, newer support: track it, publish nothing
continue
rid = str(uuid.uuid4())
self.reviews[rid] = {"claim": claim.claim_id, "trigger": doc.doc_id, "diff": d,
"new": new, "docs": docs, "status": "pending_review"}
self.notify({"review_id": rid, "claim": claim.claim_id, "trigger": doc.doc_id,
"published_in": claim.published_in, **d})
opened.append(rid)
return opened
def approve(self, review_id: str, reviewer: str) -> None:
if not reviewer.strip():
raise PermissionError("approval needs a named reviewer")
review = self.reviews[review_id]
if review["status"] != "pending_review":
raise PermissionError(f"review {review_id} is {review['status']}")
review |= {"status": "approved", "reviewer": reviewer}
self.audit.write("review_approved", review=review_id, reviewer=reviewer)
def apply(self, review_id: str, publish: Callable[[Claim], None]) -> None:
"""The only path to customers: a named person approved this exact diff."""
review = self.reviews[review_id]
if review["status"] != "approved":
raise PermissionError("not approved; nothing is republished automatically")
claim = self.claims[review["claim"]]
claim.answer, claim.depends_on = review["new"], review["docs"]
publish(claim)
review["status"] = "applied"
self.audit.write("claim_republished", review=review_id, claim=claim.claim_id)- Tolerances belong in the reference, per task, as FinFIRST does. A growth rate to one decimal place and a revenue figure in millions need different tolerances, and one global threshold hides both kinds of error.
- Claims marked 'latest' follow every new document for the entity; fixed-period claims move only when the document they cite is superseded by a restatement or amendment. That keeps re-runs, and token spend, proportional to what actually changed.
- Material is a business decision. Half a percent of relative change is a sensible default for published figures, and a change of unit or a figure that disappears always opens a review. When the figure holds but the support moves to a newer document, the claim starts tracking that document and nothing is republished.
“Retrieval finds the page. Accuracy comes from the arithmetic you take away from the model and the evidence you make it show.”
Where This Belongs
- 01Research and quant teams: put the calculator and span binding in front of any model that answers numeric questions over filings, transcripts or internal research, and run the documents-only baseline on your own question set before deciding how much to spend on retrieval. FinSkillBench's split between numerically intensive and procedural tasks is a useful way to bucket that set.
- 02Trading education platforms: lessons and worked examples that quote company figures go stale when results land. Register each figure as a claim with its source documents, let the refresher open a review when a filing changes it, and keep the explanation descriptive, never advisory.
- 03Brokers and CFD providers publishing research or market commentary: score generated content on its evidence, not only on whether the headline number is right, and treat any refreshed figure as a new communication that a named person approves before it reaches clients.
- 04Fintech and data startups building research assistants: use the token budget from step 2 to keep unit costs predictable as usage grows, and for questions over your own databases, borrow FLINT's idea of a lookup tool that resolves concepts to keys before any SQL is written.
The Bottom Line
The research published between 21 September and 6 October 2026 points one way: financial RAG gets more accurate when the model is given tools that compute, when retrieval starts small and escalates only where predicted gain per token justifies it, and when answers are scored on the evidence behind them as well as the final number. FinSkillBench measured 19.5 points from executable tools against 5.6 from documents, the FinanceBench escalation study used 46.6% fewer tokens than five pages for every question, and FinFIRST found 17.48% of correct-answer attempts without a complete evidence chain. AlphaSense's always-on SuperAnalyst points to the last piece, keeping answers tied to their sources as new filings arrive. Put together, that is a trading AI architecture in which code does the arithmetic, a model explains, and a person signs off on anything that reaches a client, which is how AlchmAI, a London trading technology and AI studio, approaches market data and research systems.
References & Further Reading
- AlphaSense - AlphaSense Launches Next-Generation AI Agent SuperAnalyst to Power High-Value Financial and Strategic Workflows. alpha-sense.com/press/alphasense-launches-next-generation-ai-agent-superanalyst
- AlphaSense - AlphaSense Expands Enterprise AI Security and Governance as Businesses Demand Verifiable, Trustworthy AI. alpha-sense.com/press/alphasense-expands-enterprise-ai-security-and-governance
- arXiv - Knowledge or Calculator? Decomposing the Skill Premium in Verifiable Financial Agent Workflows (Bek, Bok and Sun, 2 October 2026). arxiv.org/abs/2610.03564
- arXiv - Token-Budgeted Escalation for Financial Document QA: Cost Is Predictable, Benefit Is the Bottleneck (Zhu, Yang, Ding and Qi, 6 October 2026). arxiv.org/abs/2610.07760
- arXiv - FinFIRST: Benchmarking Search Agents for Financial Information Retrieval, Sourcing and Traceability (21 September 2026). arxiv.org/abs/2609.25192
- arXiv - From Benchmarks to Production: A Text-to-SQL System for Complex Financial Data (2 October 2026). arxiv.org/abs/2610.03524
- Anthropic - Models overview. platform.claude.com/docs/en/models/overview
- Anthropic - Token counting. platform.claude.com/docs/en/build-with-claude/token-counting
- Anthropic - Structured outputs. platform.claude.com/docs/en/build-with-claude/structured-outputs
- Anthropic - Refusals and fallback. platform.claude.com/docs/en/build-with-claude/refusals-and-fallback
- scikit-learn - TfidfVectorizer (scikit-learn 1.9.1 documentation). scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html
- AlchmAI - Market data infrastructure. alchmai.com/solutions/market-data-infrastructure
- AlchmAI - Trading education platforms. alchmai.com/solutions/trading-education-platforms
AlchmAI Engineering
Engineering, London
Written by the AlchmAI engineering team in Mayfair, London. We build trading platforms, real-time charts, market data pipelines and AI features for brokers, prop firms and fintech teams. The Playbook is where we explain how we approach these systems, with code you can run and sources you can check.
Code in this guide is illustrative and supplied without warranty. Review and test it before production use. Nothing here is investment advice. Important information