The LLM Should Not Own Portfolio Risk
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 → TextThat 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.
| Responsibility | Owns it | Why |
|---|---|---|
| Intent, explanation, tool selection | Foundation model on Bedrock | Language and reasoning |
| Holdings, percentages, HHI, stress PnL | C# | Repeatable, testable, auditable |
| Who may see which book | Application authorization | The model is not an ACL |
| Content policy on prompts and completions | Bedrock 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:
| Tool | Returns |
|---|---|
| `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:
| Layer | What it hides |
|---|---|
| Bedrock | Which 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.
| Scenario | Equity | Bond | Cash |
|---|---|---|---|
| EQUITY-DOWN-10 | −10% | 0 | 0 |
| EQUITY-DOWN-20 | −20% | 0 | 0 |
| 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.
| Layer | Example | Failure if omitted |
|---|---|---|
| Authentication | Workload identity / user token | Unattributed inference |
| Authorization | User may not read PF-BALANCED | Cross-book leakage via a tool argument |
| Input validation | Closed set of scenario names | Arbitrary shock strings |
| Bedrock Guardrails | Denied topics, PII filters | Policy gaps on free text |
| IAM condition on guardrail | API must attach a named guardrail | Safety 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
| Next | Why |
|---|---|
| AuthZ on portfolio id | Tool arguments remain untrusted |
| Guardrails required by IAM | Policy is not a code comment |
| Licensed market-data tool | Live prices without hallucination |
| RAG over filings | Narrative context that is still cited |
| Exported traces and evals | Cost, quality, and regression gates |
| IaC and container deploy | Same 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
From AI Model Consumer to AI Application Builder
A practical guide for .NET engineers moving from chat prompts to RAG, MCP servers, agents, and agentic workflows — with security patterns, architecture diagrams, and platform mental models.
Building Finance MCP Servers
Finance-specific MCP patterns — equity search, portfolio resources, order tools with approval gates, and audit-friendly error handling for regulated workloads.
How AI Agents Run on Kubernetes
AI agents run on Kubernetes as ordinary workloads — Deployments, Services, HPAs — plus queues for long jobs and GPU nodes for self-hosted models. With finance examples.