Back$ kuntal_pal

> personal project · fintech

Intelligent Financial Advisor — Finley

A production-grade multi-agent financial advisor that runs a full quantitative pipeline — ARIMA forecasting, Markowitz optimisation, VaR risk metrics, and Isolation Forest anomaly detection — then synthesises the results into a structured advisory report via Claude.

  • LangGraph
  • Claude API
  • ARIMA
  • Markowitz
  • Isolation Forest
  • FastAPI
  • Docker
  • Python

Live Demo

Try It

Enter a portfolio (e.g. AAPL, MSFT, JPM), optional weights, and ask Finley for an analysis. The first message triggers the full ML pipeline; follow-ups are answered directly from the prior context.

Open in full screen at huggingface.co/spaces/kpal002/intelligent-financial-advisor

Background

The Problem

Institutional trading desks run quantitative analysis as routine — ARIMA-based trend forecasting, mean-variance optimisation, Value-at-Risk calculations, and anomaly detection on return distributions. Retail investors don't have access to any of it. They get a number (their portfolio return) and a guess.

The goal: make that institutional analysis accessible through a conversational interface that explains every number it produces, in plain English, referenced back to the user's actual holdings.

The harder design challenge is making this interactive — not just a one-shot report, but a system that can answer drill-down questions about its own output without re-running the expensive pipeline on every turn.

Architecture

The 5-Node LangGraph Pipeline

The first user message triggers a LangGraph state machine with five specialist nodes arranged in a sequential graph. Each node reads the accumulated AdvisorState TypedDict, performs its computation, and writes its outputs back — creating a typed, inspectable data contract between every stage of the pipeline.

Pipeline Flow

START

user query

📡

01

market_research

📊

02

risk_analysis

🔍

03

recommendation

🧠

04

synthesize

05

validate

END

report

validate re-routes to synthesize on confidence < threshold

langgraph_pipeline.py — typed state contract
class AdvisorState(TypedDict):
    user_query: str
    portfolio_symbols: list[str]
    current_allocation: dict[str, float]
    # ↓ populated progressively as nodes execute
    market_data: dict          # → market_research
    risk_metrics: dict         # → risk_analysis
    recommendations: dict      # → recommendation
    advisory_report: str       # → synthesize
    validation_result: dict    # → validate

The graph is compiled once at startup with workflow.compile() and reused across all requests — keeping cold-start overhead to a single import cycle. Each node function is a pure Python callable that accepts and returns AdvisorState; LangGraph handles the execution, error propagation, and conditional edge routing.

Node Deep Dives

📡

Node 01

market_research

Data & Forecasting

Inputs

  • portfolio_symbols — list of ticker strings
  • current_allocation — user-supplied weight map
  • user_query — raw question string

Tools & Algorithms

  • yfinance — 1-year daily OHLCV price history per symbol
  • statsmodels ARIMA — AIC-minimising order selection (p,d,q)
  • 30-day point forecast + 95% confidence interval
  • RSI (14-day Wilder) — trend classification: bullish / bearish / range-bound
  • Trend slope from linear regression on log-returns

Outputs

  • market_data dict keyed by symbol
  • forecast_30d — expected price in 30 days
  • trend — bullish | bearish | neutral
  • rsi — current RSI value
  • price_history — DataFrame passed downstream

Design Note

ARIMA order selection runs per-symbol rather than using a fixed (1,1,1) model. AIC minimisation picks the best-fitting model for each asset's autocorrelation structure. This adds ~2s per symbol but produces confidence intervals that actually reflect the asset's volatility rather than a generic band.

📊

Node 02

risk_analysis

Portfolio Risk

Inputs

  • price_history DataFrames from market_research
  • current_allocation — existing weights
  • portfolio_symbols — for joint covariance estimation

Tools & Algorithms

  • PortfolioRiskAnalyzer — custom class wrapping scipy + numpy
  • Historical VaR at 95th percentile on daily log-returns
  • CVaR — mean of losses exceeding the VaR threshold (tail risk)
  • Sharpe ratio — excess return / total volatility (annualised)
  • Sortino ratio — excess return / downside deviation only
  • scipy.optimize — constrained max-Sharpe on the efficient frontier
  • Max drawdown — peak-to-trough on the equity curve

Outputs

  • risk_metrics — full metric dict per portfolio
  • recommended_allocation — Markowitz-optimal weight vector
  • portfolio_risk_level — LOW | MEDIUM | HIGH | VERY HIGH
  • expected_return and volatility (annualised)

Design Note

Markowitz optimisation is solved as a minimisation of negative Sharpe (rather than maximising Sharpe directly) so scipy's constrained minimiser can be used without a separate maximiser. Weights are constrained to [0, 1] with sum=1 to prevent short positions, which keeps the output valid for retail investors who can't short.

🔍

Node 03

recommendation

Signal Generation

Inputs

  • market_data — forecasts, RSI, trend slopes
  • risk_metrics — Sharpe, VaR, portfolio risk level
  • price_history — rolling return matrices per symbol

Tools & Algorithms

  • scikit-learn IsolationForest — anomaly detection on 90-day normalised returns
  • Contamination parameter tuned to flag ~10% of observations
  • Composite scoring: ARIMA trend + RSI + anomaly score → signal
  • Confidence calibration — score mapped to [0, 1] via sigmoid

Outputs

  • recommendations dict per symbol
  • action — BUY | HOLD | SELL
  • confidence — float [0, 1]
  • reasoning — short string explaining the signal

Design Note

Isolation Forest detects anomalous return behaviour without predefined thresholds — a stock behaving unusually relative to its own 90-day history gets flagged regardless of whether it's technically overbought or oversold. This catches momentum breaks and volatility regime shifts that RSI-only systems miss. The final signal is a weighted composite, not a single-model output.

🧠

Node 04

synthesize

Claude LLM

Inputs

  • market_data — all ARIMA forecasts, RSI values, trend labels
  • risk_metrics — VaR, CVaR, Sharpe, Sortino, max drawdown, allocation
  • recommendations — BUY/HOLD/SELL signals with confidence
  • user_query — original question to answer directly

Tools & Algorithms

  • Claude claude-haiku-4-5 via Anthropic SDK
  • Structured prompt with quantitative data serialised as JSON
  • Embedded FINLEY_METRICS HTML comment for frontend dashboard extraction
  • Instruction to produce markdown tables, plain-English explanations, actionable steps

Outputs

  • advisory_report — full markdown string
  • <!-- FINLEY_METRICS {...} --> comment block at report head
  • Structured sections: Executive Summary, Asset Analysis, Risk, Allocation, Outlook

Design Note

Claude's prompt receives all quantitative outputs as a structured JSON block — not as prose. This forces the model to reason from numbers rather than pattern-matching to generic financial advice. The FINLEY_METRICS comment block is a machine-readable channel embedded in the text response: the frontend strips it before rendering, parses the JSON, and builds the visual dashboard above the markdown.

Node 05

validate

Quality Gate

Inputs

  • advisory_report — synthesize node output
  • recommendations — expected signals to cross-check
  • risk_metrics — expected metric ranges

Tools & Algorithms

  • Claude claude-haiku-4-5 — lightweight validation call
  • Cross-check: report mentions all portfolio symbols
  • Cross-check: BUY/HOLD/SELL signals match recommendations dict
  • Confidence score extraction from validation response
  • Conditional edge: re-route to synthesize if score < 0.7

Outputs

  • validation_result — {confidence: float, issues: list[str]}
  • Graph terminates at END if confidence ≥ 0.7
  • Graph re-routes to synthesize node if confidence < 0.7

Design Note

The validation node is a lightweight second Claude call — it reads the report and the raw quantitative outputs and checks for internal consistency. The conditional re-route is a core LangGraph feature: add_conditional_edges() lets the graph branch dynamically based on node output without any ad-hoc if-statements in the orchestration layer. In practice the re-route fires rarely, but it catches hallucinated tickers or reversed signals.

Quantitative Methods

What the Numbers Mean

Every metric in the report is computed from real price data on each request. No cached or synthetic figures.

ARIMA Forecasting

Auto-fits an ARIMA(p,d,q) model per symbol using AIC-minimising order selection. Produces a 30-day point forecast plus upper and lower 95% confidence bands. The RSI (14-day) is overlaid to classify the current trend as bullish, bearish, or range-bound.

Risk Metrics (VaR / CVaR / Sharpe / Sortino)

Historical VaR at the 95th percentile gives the worst expected daily loss with 5% probability. CVaR (Conditional VaR) averages the losses beyond that threshold — it captures tail risk that VaR misses. Sharpe ratio measures risk-adjusted return against the risk-free rate; Sortino uses only downside deviation, penalising volatility that hurts the investor rather than volatility in general.

Markowitz Optimisation

Solves for the max-Sharpe portfolio on the efficient frontier using scipy's constrained minimiser. The output is an optimal weight vector that the report compares against the user's current allocation — surfacing over- and under-weight positions.

Isolation Forest Anomaly Detection

Fits an Isolation Forest on the rolling 90-day normalised return matrix. Anomaly scores drive the BUY / HOLD / SELL signals — symbols with highly anomalous recent returns (relative to their own history) get flagged for attention regardless of their ARIMA trend.

Conversation Design

Multi-Turn Memory Without Re-Running the Pipeline

Running the full ML pipeline on every message would be slow and expensive. The routing strategy keeps follow-up responses fast while preserving full conversational depth.

First message — full pipeline

  • History array is empty → trigger LangGraph graph
  • All 5 nodes run: market data → risk → signals → Claude synthesis → validation
  • Full advisory report returned as the first assistant turn
  • Report content becomes the context for all future follow-ups

Follow-up messages — Claude direct

  • History array is non-empty → skip LangGraph entirely
  • Full conversation history reconstructed as LangChain messages
  • Claude (claude-haiku-4-5) answers with the prior report as context
  • Sub-second response time — no data fetching, no ML inference
main.py — routing logic
@app.post("/chat")
async def chat(req: ChatRequest):
    symbols, allocation = _parse_portfolio(req.symbols, req.weights)

    # Follow-up: history present → Claude answers directly
    if req.history:
        return ChatResponse(
            response=_followup_response(req, symbols),
            live=True,
        )

    # First message: run the full LangGraph pipeline
    result = get_advisor().invoke(
        user_query=req.message,
        portfolio_symbols=symbols,
        current_allocation=allocation,
    )

Design Decisions

Why These Choices?

Why LangGraph instead of a simpler sequential script?

LangGraph gives the pipeline a typed state object that flows between nodes, built-in graph compilation, and a clear extension point for adding parallel branches or conditional re-routing (the validation node already re-routes to synthesize on low confidence). A raw sequential script would need to reinvent that plumbing.

Why route follow-ups to Claude directly instead of re-running the pipeline?

The full pipeline takes 15–30 seconds depending on the number of symbols — acceptable for the initial report, not for a follow-up question like 'what does my Sharpe ratio mean?' Claude already has the report in its context window and can answer instantly. The routing decision (empty history vs. non-empty history) is a single if-statement in the backend.

Why Isolation Forest for buy/hold/sell signals?

Most retail-facing tools use rule-based signals (RSI thresholds, moving average crossovers). Isolation Forest detects anomalies without requiring predefined thresholds — a symbol that's behaving unusually relative to its own recent history gets flagged regardless of whether it's overbought or oversold. That's a more useful signal for portfolios with diverse assets.

Future Plans

Where This Is Going

Next

Streaming Responses

Stream Claude's synthesis token-by-token via server-sent events so the report appears progressively rather than after a full pipeline run.

Next

Sector & Macro Context

Add a macro_context node that fetches Fed rate decisions, sector performance, and earnings calendar as additional signals for the synthesize node.

Later

Persistent Sessions

Store conversation history server-side (Redis or Postgres) so users can return to a previous analysis session from any device.

Later

Options & Derivatives

Extend the market_research node to include options chain data — implied volatility surface, put/call ratios — for portfolios that use derivatives for hedging.

Later

Backtesting Node

Add a backtesting node that runs the Markowitz-optimal allocation against historical data and reports actual vs. expected Sharpe over rolling windows.