← Back to Writing
Article· 6 min read

The LLM Should Not Own Portfolio Risk

Amazon BedrockAWS Architecture.NET AIAI EngineeringFintechOpenTelemetry
Architecture of a Bedrock portfolio risk service: ASP.NET Core, IChatClient, Converse, Nova 2 Lite, and trusted C# finance tools

Summary

Bedrock for language and tool selection; C# for allocations, HHI, and stress. IAM, Guardrails, and IChatClient as separate layers — not a chat wrapper.

Short answer: Do not let a foundation model invent portfolio numbers. Use Amazon Bedrock for language, tool selection, and explanation. Keep holdings, allocations, concentration, and stress results in deterministic C#. The model may request a capability. The application still authorizes, executes, and returns verified facts.

That split is the difference between wrapping a chat API and engineering an AI system on AWS. Reference implementation: bedrock-portfolio-risk. Holdings are synthetic. Credentials stay in the AWS provider chain — not in source.

This sits in the same progression as From AI Model Consumer to AI Application Builder. Adjacent patterns for finance tools and approval gates are in Building Finance MCP Servers. Runtime placement of model-calling services is covered in How AI Agents Run on Kubernetes.

Why chat wrappers fail in risk

The default integration is a single hop:

Prompt → LLM → Text

That is fine for a glossary question. It is not fine for "what is equity exposure on this book?" An LLM is probabilistic. Portfolio math is not.

ResponsibilityOwns itWhy
Intent, explanation, tool selectionFoundation model on BedrockLanguage and reasoning
Holdings, percentages, HHI, stress PnLC#Repeatable, testable, auditable
Who may see which bookApplication authorizationThe model is not an ACL
Content policy on prompts and completionsBedrock Guardrails (optional)Complements IAM; does not replace it

If equity weight, sector concentration, or a shock result is generated from training data, the system has already failed — even when the prose sounds like a PM letter.

What this service actually does

Two reference books exist in process memory: PF-GROWTH and PF-BALANCED. Natural-language questions hit an ASP.NET Core API. The model never receives the full blotter in the system prompt.

It receives two typed tools:

ToolReturns
`GetPortfolioRiskSummary`Market value, asset-class weights, largest name and sector, HHI, risk label
`RunStressScenario`Base vs shocked value for `EQUITY-DOWN-10`, `EQUITY-DOWN-20`, or `RATES-UP-100BPS`

Typical questions:

  • Analyze PF-GROWTH. Where is concentration?
  • Run EQUITY-DOWN-20 on PF-GROWTH and compare the outcome with PF-BALANCED.

Bedrock Converse emits a tool request. C# validates the arguments, computes, and returns a structured result. The model then explains that result.

A useful negative case: a live equity price. There is no market-data tool. The correct completion is that the information is unavailable — not a fabricated quote. Grounding is a product decision, not a hope that the model behaves.

Architecture

The load-bearing edge is the tool request. Inference is interchangeable. Execution is not.

I use Bedrock Runtime and Converse, not an OpenAI-compatible facade. The goal is AWS IAM, the AWS SDK for .NET V4, and one message-oriented contract across Converse-capable models. Microsoft.Extensions.AI sits above that so application code depends on `IChatClient`, not on Nova-specific request JSON.

Those two abstractions solve different problems:

LayerWhat it hides
BedrockWhich foundation model you invoke
`IChatClient`Which provider SDK the process talks to

Stack and configuration

.NET 10 is the LTS I would start a new service on. AWS SDK for .NET V4 is the current SDK generation. The Bedrock MEAI package is the bridge:

IChatClient chatClient = bedrockRuntime.AsIChatClient(modelId);

Model identity belongs in configuration, not in business types:

{
  "Bedrock": {
    "Region": "us-east-1",
    "ModelId": "us.amazon.nova-2-lite-v1:0"
  }
}

Amazon Nova 2 Lite is a Bedrock-native, Converse-capable, cost-aware reasoning model with tool use. The interesting property is not the brand. It is that changing `ModelId` is a configuration change — and that a configuration change is still a release that needs evaluation.

Identity stays outside the process

There is no access key in source. The runtime client is constructed with a region only:

builder.Services.AddSingleton<IAmazonBedrockRuntime>(_ =>
    new AmazonBedrockRuntimeClient(RegionEndpoint.USEast1));

Local development uses the AWS credential chain (Identity Center or another short-lived path). Workloads in AWS use a task, pod, or execution role. Long-lived keys in GitHub are an incident, not a shortcut.

IAM still needs Converse / InvokeModel on the chosen model. Guardrail attachment can later be required by IAM condition keys so a developer cannot "forget" the safety policy.

Function calling is the control loop

Microsoft.Extensions.AI's function-invocation middleware runs the round trip: messages out, tool request in, C# invoke, tool result back, possibly another model turn. Without it, you write that loop by hand. With it, you still treat every argument as untrusted input.

Unknown portfolio ids fail in C#. Unknown scenarios fail in C#. Iteration caps and consecutive-error caps bound a runaway tool loop. Detailed tool errors should not be echoed back to the model in a production book.

return bedrockClient
    .AsBuilder()
    .UseFunctionInvocation(loggerFactory, configure: options =>
    {
        options.MaximumIterationsPerRequest = 6;
        options.MaximumConsecutiveErrorsPerRequest = 2;
        options.IncludeDetailedErrors = false;
    })
    .UseOpenTelemetry(loggerFactory, "BedrockPortfolioRisk", telemetry =>
    {
        telemetry.EnableSensitiveData = false;
    })
    .Build(serviceProvider);

Tools are registered through `AIFunctionFactory` onto `ChatOptions.Tools`. Temperature is pinned low because this is analysis over structured facts, not creative writing.

Risk math stays in C#

PF-GROWTH is concentrated in two technology names. The model should not reconstruct that from prose.

[Description("Returns deterministic risk metrics for a portfolio.")]
public PortfolioRiskSummary GetPortfolioRiskSummary(string portfolioId)
{
    var holdings = repository.Get(portfolioId);
    var total = holdings.Sum(x => x.MarketValue);
    // asset-class weights, largest name and sector, HHI, risk label
}

Stress is a second function with a closed scenario set. Shocks are simplified on purpose: enough to show architecture, not a proprietary risk engine, and not investment advice.

ScenarioEquityBondCash
EQUITY-DOWN-10−10%00
EQUITY-DOWN-20−20%00
RATES-UP-100BPS−3%−6%0

The intended conversation is: the model decides which facts it needs; C# decides whether those facts may be computed and what the numbers are.

Prompt, ACL, and Guardrails are different layers

A system prompt can require tool use for book-specific facts, forbid invented market data, and refuse personalized buy/sell language. That is guidance. It is not a security boundary.

LayerExampleFailure if omitted
AuthenticationWorkload identity / user tokenUnattributed inference
AuthorizationUser may not read PF-BALANCEDCross-book leakage via a tool argument
Input validationClosed set of scenario namesArbitrary shock strings
Bedrock GuardrailsDenied topics, PII filtersPolicy gaps on free text
IAM condition on guardrailAPI must attach a named guardrailSafety becomes optional in code

If Alice may only see one book, a tool call for another id must fail in application code — the same way a REST handler would. Guardrails sit on prompts and completions. They do not replace portfolio entitlements.

Observability that does not become a data leak

`POST /analyze took 2.8s` is an API metric, not an AI metric. You also need model id, input and output tokens, tool name, tool latency, throttles, time to first token, and how many model turns occurred.

You do not need full prompts, account identifiers, and positions in the trace store. `EnableSensitiveData = false` is a product decision. Observability that dumps the blotter is a leak with a dashboard.

Streaming is a separate latency story. Total generation time is not time to first token. `GetStreamingResponseAsync` maps to ConverseStream for supported models.

This is not an agent platform

The workflow is question → reason → call trusted tools → explain. `IChatClient` plus function invocation is the right size.

Reach for an agent framework when you have durable sessions, multi-agent handoffs, long-running work, recovery, or human approval in the path. Two finance functions do not justify that surface. Use the smallest AI abstraction that solves the workflow.

Portability is not equivalence

Changing `ModelId` is easy. Nova versus another Converse model still differs on tool-selection quality, latency, cost, safety, context window, and structured-output reliability. Abstraction makes the switch cheap. An evaluation suite — grounding, tool choice, hallucination rate, tokens, cost — decides whether the switch is allowed in production.

What I would add next

NextWhy
AuthZ on portfolio idTool arguments remain untrusted
Guardrails required by IAMPolicy is not a code comment
Licensed market-data toolLive prices without hallucination
RAG over filingsNarrative context that is still cited
Exported traces and evalsCost, quality, and regression gates
IaC and container deploySame identity model in AWS

Version one stays small: two tools, one API, one model configuration.

Closing

The model is not the application.

Amazon Bedrock is the managed inference plane. The AWS SDK is native IAM and service integration. Microsoft.Extensions.AI is the .NET application contract. C# remains responsible for what a financial institution actually has to trust: truth, permissions, business logic, and execution.

Frequently asked questions

Why Amazon Bedrock for this architecture?
In an AWS-centric platform, Bedrock is the inference control plane: multiple models, IAM, VPC patterns, and Guardrails. Application code should not take a direct dependency on one vendor's request JSON.
Why Converse instead of InvokeModel?
InvokeModel is model-specific. Converse is a unified conversational interface across Bedrock models that support it, which is what tool calling and IChatClient need.
Why calculate risk in C#?
Portfolio math must be deterministic and auditable. The model selects tools and explains results. C# owns balances, percentages, shocks, and authorization.
Is this an autonomous agent?
No. It is a bounded question-reason-tool-explain loop. Agent frameworks belong with durable workflows, handoffs, and human approval — not two trusted finance functions.
Where is the code?
github.com/yashshah7575/bedrock-portfolio-risk. Synthetic holdings only. AWS credentials stay in the provider chain, not in the repository.

Related reading