Developer GuideFinancial literacy (clients)

Financial literacy integration guide

Copinance OS adapts every text output — LLM responses, regime labels, report summaries, options narration — to the caller’s financial literacy tier. This guide covers the three ways to set literacy, how the precedence chain works, and what each tier actually produces.


Tiers

ValueEnum constantWho it’s for
"beginner"FinancialLiteracy.BEGINNERPlain-language summaries; every term defined on first use; no acronym stacks
"intermediate"FinancialLiteracy.INTERMEDIATEStandard market vocabulary; brief glosses for niche concepts; light structure
"advanced"FinancialLiteracy.ADVANCEDInstitutional vocabulary; second-order effects; options structure; no tutorial copy

The default when nothing is set is intermediate.


Precedence

AnalyzeInstrumentRequest.financial_literacy   ← highest priority
  (also: AnalyzeMarketRequest, MarketNarrativeRequest, GenerateCuratedQuestionsRequest)
  └─ AnalysisProfile.financial_literacy       ← used when request field is None
       └─ intermediate (DEFAULT)              ← used when no profile is attached
                                                (logs a WARNING)

The request field always wins. You do not need a profile to set literacy.


This is the simplest path. No profile, no persistence, no extra round-trip.

from copinance_os import (
    AnalyzeInstrumentRequest,
    AnalyzeMarketRequest,
    FinancialLiteracy,
)
from copinance_os.infra.di import get_container
 
container = get_container(llm_config=my_llm_config)
 
# Per-request override — maps directly from your user session
user_literacy = FinancialLiteracy(user.preferred_literacy)  # e.g. "beginner"
 
result = await container.analyze_instrument_use_case().execute(
    AnalyzeInstrumentRequest(
        symbol="AAPL",
        question="What is the current options flow telling us?",
        financial_literacy=user_literacy,
    )
)

The same field exists on AnalyzeMarketRequest:

result = await container.analyze_market_use_case().execute(
    AnalyzeMarketRequest(
        question="Summarise macro conditions in plain English.",
        financial_literacy=FinancialLiteracy.BEGINNER,
    )
)

The same field applies to MarketNarrativeRequest and GenerateCuratedQuestionsRequest (Ask AI chips grounded in a payload you already fetched). See Curated questions (clients).

When to use: server-side apps where your user model already knows the preference, or any context where you want stateless, per-request control.


Option 2 — Persist literacy in a profile (CLI / multi-session apps)

Use profiles when you want the user’s preference stored server-side and attached to any request without re-passing it.

from copinance_os.research.workflows.profile import (
    CreateProfileRequest,
    SetCurrentProfileRequest,
)
from copinance_os.domain.models.entities.profile import FinancialLiteracy
from copinance_os.infra.di import get_container
 
container = get_container()
 
# Create once per user
response = await container.create_profile_use_case().execute(
    CreateProfileRequest(
        financial_literacy=FinancialLiteracy.INTERMEDIATE,
        display_name="Alice",
    )
)
profile_id = response.profile.id  # store this in your user DB
 
# Attach to every request — no financial_literacy field needed
result = await container.analyze_instrument_use_case().execute(
    AnalyzeInstrumentRequest(
        symbol="NVDA",
        question="What's driving the implied volatility skew?",
        profile_id=profile_id,        # literacy comes from profile
        # financial_literacy=None     # (default) — profile fills in
    )
)

Precedence reminder: if you pass both profile_id and financial_literacy on a request, the financial_literacy field wins. This lets you temporarily override a profile without updating it.


Option 3 — Raw job API (advanced)

If you build Job objects manually (e.g. custom queue / batch), set financial_literacy in the context dict directly:

from copinance_os.domain.models.job import Job, JobScope, JobTimeframe
 
job = Job(
    scope=JobScope.INSTRUMENT,
    market_type=MarketType.EQUITY,
    instrument_symbol="TSLA",
    timeframe=JobTimeframe.MID_TERM,
    execution_type="question_driven_instrument_analysis",
    profile_id=None,
)
context = {
    "question": "Explain the Sharpe ratio in simple terms.",
    "financial_literacy": "beginner",   # string value of the enum
    "stream": False,
}
result = await container.research_orchestrator().run_job(job, context)

DefaultJobRunner logs a WARNING if financial_literacy is absent from the context and no profile is attached. In that case the output defaults to intermediate.


What changes between tiers

LLM (question-driven) responses

The system prompt injects a mandatory output contract block. Each tier has binding rules:

TierMandatory rules
beginnerLead with one plain-English sentence. Define every finance term on first use. Prefer everyday analogies. No acronym stacks.
intermediateStandard market terms. Brief gloss for less common concepts. Mix intuition with light structure.
advancedFull institutional vocabulary. No tutorial definitions. Chain second-order effects, cross-asset context, options structure directly.

Deterministic output (regime labels, macro interpretation, options positioning)

Tiered strings from data/literacy/ are selected at response time. For example, the market_regime literacy module maps "very_tight""very tight" (beginner) / "very_tight" (intermediate) / "very tight labor" (advanced). Options positioning uses ~40 such mappings covering GEX, vanna, charm, mispricing, and pin risk.

Report envelope

summary, assumptions, and limitations in AnalysisReport use tiered copy from data/literacy/reports.py. Machine-readable fields (key_metrics, methodology) are identical across tiers — only prose changes.


Mapping your user model to literacy tiers

A common pattern is to store literacy as a string column in your user table and convert at request time:

from copinance_os.domain.literacy import resolve_financial_literacy
 
# Handles None, unknown strings, and enum values gracefully
lit = resolve_financial_literacy(user.literacy_preference)  # returns FinancialLiteracy
 
result = await runner.run(
    AnalyzeInstrumentRequest(symbol="SPY", financial_literacy=lit)
)

resolve_financial_literacy accepts None, any FinancialLiteracy enum value, or a raw string ("beginner", "intermediate", "advanced"). Unknown values fall back to intermediate.


Multi-turn chat

Literacy is per-request, not per-session. For a chat UI, read the user’s preference at the start of the session and pass the same financial_literacy value on every turn:

async def handle_chat_turn(
    user_id: str,
    message: str,
    history: list[dict],
) -> str:
    user = await db.get_user(user_id)
    lit = resolve_financial_literacy(user.literacy_preference)
 
    result = await container.analyze_market_use_case().execute(
        AnalyzeMarketRequest(
            question=message,
            conversation_history=[
                LLMConversationTurn(**turn) for turn in history
            ],
            financial_literacy=lit,
        )
    )
    return result.report.summary if result.report else ""

Checking what tier is active

The executor records financial_literacy in structlog context for every run. In structured logs, look for financial_literacy on job_run_start and job_run_success events. In results, RunJobResult.results for question-driven runs includes "financial_literacy" in the top-level dict.


Common mistakes

MistakeFix
Passing financial_literacy="Beginner" (mixed case)Use the enum: FinancialLiteracy.BEGINNER, or the lowercase string "beginner". resolve_financial_literacy() normalises, but be consistent.
Setting literacy on the profile then wondering why the override doesn’t workRequest-level financial_literacy always wins over the profile. Set it to None on the request to let the profile take effect.
Creating profiles defaulting to beginnerThe default is now intermediate. Existing profiles created before this change may have beginner stored — migrate or re-create them if needed.
Expecting literacy to affect key_metrics numbersLiteracy only changes prose fields (summary, assumptions, limitations, interpretation labels). All numeric values are tier-independent.