FinAgentic — Multi-Agent Financial Analysis Platform
A multi-agent research platform where an orchestrator dispatches to specialists — filings analyst, news analyst, quantitative analyst — and reconciles their findings into a cited report. The capstone project.
Stack at a glance
- Backend
- Spring Boot 3.3+LangChain4jSpring AIJava 21
- Data
- PostgreSQLTimescaleDBQdrantNeo4jRedis
- Streaming
- Apache KafkaSpring Cloud Stream
- AI
- Claude SonnetGPT-4oCross-encoder re-ranker
- Ops
- KubernetesOpenTelemetryGrafanaLangFuse
On this page
The capstone. Where DevAgentic had one agent with many tools, FinAgentic has several agents with different specialisms and a coordinator that decides who does what.
Why multiple agents at all
The honest answer: usually you should not. A single agent with a good tool set beats a multi-agent system on cost, latency and debuggability for almost every problem.
Multiple agents earn their keep when the sub-tasks need genuinely different system prompts, tool sets and models:
| Agent | Prompt shape | Tools | Model |
|---|---|---|---|
| Filings analyst | Careful, literal, citation-obsessed | Vector search over filings | Strong reasoning |
| News analyst | Fast, high volume, sceptical of sources | News search, sentiment | Cheap and fast |
| Quantitative analyst | Never estimates; always calls a tool | Deterministic calculators | Cheap; tools do the work |
| Orchestrator | Routing and reconciliation only | Delegation only | Mid-tier |
Those are four different jobs. Merging them produces a system prompt that contradicts itself.
Architecture
Module 1 — Specialists as typed interfaces
LangChain4j's AiServices fits multi-agent work particularly well: each agent is an interface with
its own prompt, tools and typed return.
package com.javaagentic.finagentic.agents;
import java.util.List;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
public interface FilingsAnalyst {
record Finding(
String claim,
// Every claim carries its source. A finding without a citation is
// discarded during reconciliation rather than reported.
String sourceDocument,
int page,
String verbatimQuote,
Confidence confidence) {
enum Confidence { HIGH, MEDIUM, LOW }
}
record Analysis(List<Finding> findings, List<String> unanswerable) {}
@SystemMessage("""
You analyse company regulatory filings.
Rules:
1. Every claim must quote the filing verbatim and cite document and page.
2. If the filings do not answer part of the question, list it under
'unanswerable'. Never fill a gap with general knowledge.
3. Report figures exactly as stated, including the period and units.
Do not convert, annualise or extrapolate — that is the quantitative
analyst's job.
4. Never characterise a company as a good or bad investment. You
report what the filings say.
""")
Analysis analyse(@UserMessage @V("question") String question);
}public interface QuantitativeAnalyst {
@SystemMessage("""
You answer quantitative questions about price and volume history.
You must NEVER compute a number yourself. Language models make
arithmetic errors that are impossible to spot in a fluent sentence.
Every figure you report must come from a tool call.
If no tool provides what is needed, say so.
Never forecast, project or estimate a future value.
""")
QuantAnalysis analyse(@UserMessage String question);
}package com.javaagentic.finagentic.tools;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import dev.langchain4j.agent.tool.Tool;
import org.springframework.stereotype.Component;
@Component
public class FinancialCalculators {
private final PriceRepository prices;
public FinancialCalculators(PriceRepository prices) {
this.prices = prices;
}
@Tool("""
Compute the compound annual growth rate between two dates for a
ticker. Returns the CAGR as a percentage, with the start and end
values used so the caller can verify it.
""")
public String cagr(String ticker, String startDate, String endDate) {
var start = prices.closeOn(ticker, java.time.LocalDate.parse(startDate));
var end = prices.closeOn(ticker, java.time.LocalDate.parse(endDate));
if (start.isEmpty() || end.isEmpty()) {
return "ERROR: no price data for %s on one of those dates.".formatted(ticker);
}
// BigDecimal throughout. Financial arithmetic in double is a defect
// waiting for a large enough number.
BigDecimal years = BigDecimal.valueOf(
java.time.temporal.ChronoUnit.DAYS.between(
java.time.LocalDate.parse(startDate),
java.time.LocalDate.parse(endDate))
).divide(BigDecimal.valueOf(365.25), MathContext.DECIMAL64);
if (years.compareTo(BigDecimal.ZERO) <= 0) {
return "ERROR: end date must be after start date.";
}
double ratio = end.get().doubleValue() / start.get().doubleValue();
double rate = (Math.pow(ratio, 1.0 / years.doubleValue()) - 1) * 100;
return "CAGR %.2f%% (from %s on %s to %s on %s)".formatted(
rate, start.get(), startDate, end.get(), endDate);
}
}Module 2 — The orchestrator
package com.javaagentic.finagentic.orchestrator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import org.springframework.stereotype.Service;
@Service
public class ResearchOrchestrator {
private final QuestionRouter router;
private final FilingsAnalyst filings;
private final NewsAnalyst news;
private final QuantitativeAnalyst quant;
private final ReportWriter writer;
private final RunBudget.Factory budgets;
public ResearchReport research(String question, String userId) {
RunBudget budget = budgets.create(question);
Plan plan = router.decompose(question);
// Specialists are independent, so run them concurrently. On Java 21
// these block on virtual threads — thousands of concurrent in-flight
// model calls cost almost nothing in platform threads.
List<CompletableFuture<SpecialistResult>> tasks = plan.subQuestions().stream()
.map(sub -> CompletableFuture.supplyAsync(
() -> dispatch(sub, budget),
virtualThreadExecutor))
.toList();
List<SpecialistResult> results = tasks.stream()
.map(CompletableFuture::join)
.toList();
Reconciliation reconciled = reconcile(results);
return writer.compose(question, reconciled, budget.summary());
}
private SpecialistResult dispatch(SubQuestion sub, RunBudget budget) {
budget.recordDispatch();
return switch (sub.type()) {
case FILINGS -> SpecialistResult.of(filings.analyse(sub.text()));
case NEWS -> SpecialistResult.of(news.analyse(sub.text()));
case QUANTITATIVE -> SpecialistResult.of(quant.analyse(sub.text()));
};
}
/**
* Disagreement between specialists is signal, not noise. A filing that says
* one thing and a news source that says another is exactly what a reader
* needs to know — averaging them away would be the worst possible outcome.
*/
private Reconciliation reconcile(List<SpecialistResult> results) {
var claims = results.stream().flatMap(SpecialistResult::claims).toList();
var conflicts = ConflictDetector.detect(claims);
return new Reconciliation(claims, conflicts);
}
}Module 3 — GraphRAG for relationship questions
Vector search finds passages that mention an entity. It cannot answer "which of these companies share a board member?", because that fact is a path, not a passage.
@Component
public class EntityGraphTools {
private final Neo4jClient neo4j;
@Tool("""
Find how two entities (companies, people, funds) are connected.
Returns the shortest relationship path, or NOT_CONNECTED.
Use for questions about ownership, board overlap or supply chains.
""")
public String findConnection(String entityA, String entityB) {
// Parameterised. Concatenating an agent-supplied string into Cypher is
// injection with extra steps — the model is an untrusted input source.
var result = neo4j.query("""
MATCH (a:Entity {name: $a}), (b:Entity {name: $b}),
path = shortestPath((a)-[*..4]-(b))
RETURN path
""")
.bind(entityA).to("a")
.bind(entityB).to("b")
.fetch().all();
return result.isEmpty()
? "NOT_CONNECTED: no path of length 4 or less."
: PathFormatter.describe(result);
}
}Module 4 — The evaluation harness
At this complexity, "it seems better" is not a measurement.
package com.javaagentic.finagentic.eval;
import static org.assertj.core.api.Assertions.assertThat;
/**
* A fixed question set with known-correct answers, run on every prompt, model
* or retrieval change. Without it, tuning a multi-agent system is superstition.
*/
@SpringBootTest
class ResearchEvaluationTest {
@Autowired ResearchOrchestrator orchestrator;
@Autowired FaithfulnessJudge judge;
@ParameterizedTest
@MethodSource("goldenQuestions")
void answersAreGroundedInCitedSources(GoldenQuestion golden) {
ResearchReport report = orchestrator.research(golden.question(), "eval-user");
// 1. Every claim carries a citation. Uncited claims are the failure mode
// that matters most — they look identical to cited ones.
assertThat(report.claims())
.allSatisfy(claim -> assertThat(claim.citation()).isNotBlank());
// 2. The expected source was actually retrieved. If not, the problem is
// retrieval and no amount of prompt work will fix it.
assertThat(report.sourcesUsed()).contains(golden.expectedSource());
// 3. A separate model judges whether each claim follows from its quote.
// Imperfect, but it catches the confident-and-wrong cases that
// assertion-based tests cannot express.
assertThat(judge.faithfulness(report)).isGreaterThan(0.9);
// 4. Compliance: never a recommendation.
assertThat(report.text().toLowerCase())
.doesNotContain("you should buy", "we recommend", "guaranteed");
}
}Module 5 — Compliance controls
Not optional, and cheaper to build in than to retrofit:
- Disclaimer on every output, generated by code, not by the model.
- No-advice guardrail: a deterministic output filter rejecting recommendation language, running after generation and before display.
- Full audit trail: question, sub-questions, every specialist call, every source, final report, retained per your jurisdiction's requirements.
- Source recency: filings and prices carry an as-of date; the report states it. A correct answer about last quarter presented as current is a wrong answer.
- No personalisation: the system must not adapt output to an individual's circumstances, which is the line between information and advice.
Build order
- Filings RAG alone, with page-level citations. Get this genuinely good first — everything downstream inherits its quality.
- The evaluation harness, before adding a second agent. You will otherwise have no way to tell whether agent two helped.
- Deterministic calculators and the quantitative analyst.
- The orchestrator, initially routing to one specialist. Prove routing works before parallelism.
- News analyst and the Kafka pipeline.
- Reconciliation and conflict detection.
- GraphRAG, once you have questions that genuinely need it.
- Compliance filters and audit, then re-run the whole evaluation set.
What this project teaches
- When multi-agent architecture is justified — and the far more common case where it is not
- Orchestrator-worker routing, parallel dispatch and conflict reconciliation
- Why models must never do arithmetic, and how to enforce that
- GraphRAG as the answer to relationship questions vector search cannot reach
- Evaluation harnesses as the only defence against tuning by vibes
- Designing inside a regulatory boundary from the first commit
Related reading: multi-agent systems, agentic RAG advanced patterns, and AI regulations and compliance.