Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Expert~60 hoursUpdated

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:

AgentPrompt shapeToolsModel
Filings analystCareful, literal, citation-obsessedVector search over filingsStrong reasoning
News analystFast, high volume, sceptical of sourcesNews search, sentimentCheap and fast
Quantitative analystNever estimates; always calls a toolDeterministic calculatorsCheap; tools do the work
OrchestratorRouting and reconciliation onlyDelegation onlyMid-tier

Those are four different jobs. Merging them produces a system prompt that contradicts itself.

Architecture

Orchestrator-worker: specialists run independently, then findings are reconciled with disagreements surfaced rather than averaged.

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.

FilingsAnalyst.java
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);
}
QuantitativeAnalyst.java
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);
}
FinancialCalculators.java
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

ResearchOrchestrator.java
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.

EntityGraphTools.java
@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.

ResearchEvaluationTest.java
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

  1. Filings RAG alone, with page-level citations. Get this genuinely good first — everything downstream inherits its quality.
  2. The evaluation harness, before adding a second agent. You will otherwise have no way to tell whether agent two helped.
  3. Deterministic calculators and the quantitative analyst.
  4. The orchestrator, initially routing to one specialist. Prove routing works before parallelism.
  5. News analyst and the Kafka pipeline.
  6. Reconciliation and conflict detection.
  7. GraphRAG, once you have questions that genuinely need it.
  8. 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.