Investment strategy assistant for expats in Germany. Tracks ETFs explains German tax rules in plain English, and generates a weekly digest — powered by a LangGraph agent with human-in-the-loop conversation.
- Onboards you through a short questionnaire (savings, emergency fund, monthly budget, risk tolerance, horizon, investing style, salary) in a conversational chat interface — the question list is derived automatically from the strategy blueprint registry, so it always covers exactly what the recommendation logic needs.
- Proposes a strategy from a curated, versioned blueprint registry (
agent/strategy_blueprints.py): eligibility is filtered deterministically, the LLM picks among only the eligible blueprints and personalises the prose, and the result is conformance-checked against the chosen blueprint. References come from the blueprint's curated sources — never from model memory. - Generate investment suggestions Based on your strategy and tax advantages in Germany.
- Calculates taxes — Abgeltungsteuer, Teilfreistellung, Vorabpauschale, Sparerpauschbetrag — using the current German tax rules, without calling an external service.
- Generates a weekly digest on demand: portfolio snapshot, tax status, plan check, and one action item.
| Layer | Technology |
|---|---|
| Web framework | Django 5 + PostgreSQL |
| Agent / conversation | LangGraph 0.6 with PostgresSaver checkpointer |
| LLM | OpenAI Chat Completions API — model set by AGENT_MODEL (currently gpt-5.4); used only for plan and digest nodes |
| Live prices | yfinance (no API key required) |
| Frontend | HTMX (served as local static file, no build step); light theme by default with dark-mode toggle (localStorage), icon-rail navigation, collapsible chat panel on every page |
| Static files | WhiteNoise |
| German tax engine | Pure Python, no external dependencies |
| Package manager | uv |
| Database driver | psycopg3 (psycopg[binary]) + psycopg-pool |
| Local database | Docker — pgvector/pgvector:pg16 container (host port 5433) |
| Knowledge retrieval | Hybrid: pgvector cosine (HNSW index) + Postgres full-text search, fused with Reciprocal Rank Fusion |
invest-tax/
├── agent/ # LangGraph agent — no Django views here
│ ├── graph.py # StateGraph definition, PostgresSaver wiring
│ ├── state.py # AgentState TypedDict and sub-TypedDicts
│ ├── nodes.py # All node functions and routing logic
│ ├── strategy_blueprints.py # versioned blueprint registry: curated sources + eligibility conditions + allocation templates ("blueprints decide, RAG explains")
│ ├── validators.py # UCITS gate, blueprint conformance, risk alignment
│ ├── catalog.py # canonical asset types + allocation categories
│ ├── tax_engine.py # Pure Python German tax calculations
│ ├── price_service.py # yfinance wrapper
│ └── tests.py
│
├── portfolio/ # Django app — long-term storage
│ ├── models.py # UserProfile, Goal, Holding, ExitRule,
│ │ # Strategy, InvestedPortfolio, InvestedPortfolioItem
│ ├── views.py # Overview, upload CSV, add manual, tax partial,
│ │ # strategy/portfolio save·switch·delete, catalog browse
│ └── tests.py
│
├── chat/ # Django app — HTMX chat interface
│ ├── views.py # Bridges HTTP requests to LangGraph graph
│ └── tests.py
│
├── digest/ # Django app — weekly digest trigger
│ ├── views.py # Calls digest_node directly, returns HTML
│ └── tests.py
│
├── rag/ # Django app — multi-domain knowledge base
│ ├── models.py # KnowledgeSource (abstract) → TaxSource / StrategySource; shared KnowledgeChunk vector base (pgvector column + HNSW index)
│ ├── ingest.py # fetch (URL or uploaded PDF) → split on headings/pages → chunk → embed with "{title} — {section}:" prefix; cached query translation
│ ├── retriever.py # hybrid search: pgvector cosine + Postgres FTS, RRF fusion, per-source cap, calibrated relevance gate
│ ├── admin.py # add Tax / Investment Strategy sources by URL or PDF upload
│ ├── evals/ # golden Q&A set + eval runner (see `manage.py rag_eval`)
│ └── tests.py
│
├── investbuddy/ # Django project config
│ ├── settings.py
│ └── urls.py
│
├── docker-compose.yml # pgvector Postgres 16 container (host port 5433)
├── pyproject.toml # Dependencies (managed by uv)
└── uv.lock # Auto-generated lock file — do not edit
InvestBuddy uses one PostgreSQL database with two logically separate areas — Django ORM tables and LangGraph checkpoint tables. They share the same Postgres instance but never overlap. This is the most important architectural decision in the project.
Stores structured, durable user data via the Django ORM (django.db.backends.postgresql). This data survives conversation resets.
UserProfile one per user savings, emergency fund, risk profile, tax bracket
Goal many per user investment goals with target amounts and dates
Holding many per user portfolio positions (ticker, units, purchase price)
ExitRule one per Holding when to sell or review a position
Strategy many per user saved strategies (name, text, allocation data, one active)
InvestedPortfolio many per user saved AI-suggested portfolios (name, amount, one active)
InvestedPortfolioItem many per InvestedPortfolio suggested pick (ticker, €-amount, rationale)
Note: strategies used to live as flat fields on
UserProfile(strategy_approved,approved_strategy_text, …). They moved to theStrategymodel — existing dev databases must be reset (delete the DB / wipe the Docker volume and re-migrate); there is no data migration.
These tables are written by the portfolio views (manual entry, CSV upload) and read by upload_node in the agent. They represent facts that persist across sessions: "this user holds 10 units of VWCE.DE bought at €90".
Stores the full LangGraph checkpoint state for each conversation thread. This is what enables the agent to resume mid-conversation — across HTTP requests, server restarts, and browser refreshes.
Tables written by PostgresSaver:
checkpoints— one row per graph execution step, keyed bythread_id + checkpoint_idcheckpoint_blobs— binary blobs for large state fields (message lists, holdings lists)checkpoint_writes— pending writes that haven't been committed to a checkpoint yet
memory.setup() in agent/graph.py creates these tables on first run; it is idempotent and safe to call every startup.
What a checkpoint contains: the entire AgentState dict at a point in time — all messages, intake_step, savings_total, holdings, tax, approved_strategy, and current_node. LangGraph replays from the latest checkpoint on resume.
Reset behaviour: the chat reset button deletes rows from the three LangGraph tables for the current thread_id. It does not touch the Django ORM tables. After reset, UserProfile and Holding records remain intact — upload_node will re-read them from the Django DB on the next run. The user does not need to re-enter their portfolio.
┌──────────────────────────────────┐
│ │
▼ │
[INTERRUPT] → intake → route_after_intake ─┤
│
"upload" ▼
upload → fetch_prices → analysis
│
▼
┌─────────────────────── plan ◄──── "adjust"
│ │
▼ │
[INTERRUPT] → qa (intent router) ────────────┤
▲ │ │ │
│ "approve"▼ │"question" │
│ approval ▼ │
└───────────────┘ rewrite_route │
▲ (which tools?) │
│ ┌─────┼─────────┐ │
│ ▼ ▼ ▼ │
│ retrieve simulate (none) │
│ (routed (projection │
│ domains, engine) │
│ blueprint-scoped) │
│ └─────┴───────┐ │
│ ▼ │
└──────────────────────────── answer ───┘
[digest] ────────────────────────────────────────► END
(called directly by digest/views.py, outside the graph flow)
The graph is compiled with:
interrupt_before=["intake", "qa"]This tells LangGraph to pause execution before those nodes and hand control back to the caller. The graph does not run to completion in a single invoke call — it suspends and waits for the next message.
Why two interrupts?
intake— pauses before every onboarding question so the user can answer it. The answer is merged into state, theninvoke(None)resumes execution so the node can parse the answer and ask the next question.qa— the post-plan conversation loop. Every user message lands here;qa_nodeclassifies it (approve / adjust / question, strict JSON schema) and routes toapproval(save), back toplan(revise), or through the tool pipeline (rewrite_route→ only the needed tools →answer) before looping back to the interrupt.
Every user message in the chat requires two sequential graph.invoke() calls:
# Step 1: merge the user's message into the checkpoint state
# This triggers an interrupt (pauses BEFORE intake/approval) with the new message in state
graph.invoke(
{"messages": [{"role": "user", "content": user_text}]},
THREAD_CONFIG,
)
# Step 2: resume from the interrupt — the node now runs with the user message available
graph.invoke(None, THREAD_CONFIG)If you call invoke only once with the user message, it merges the message and immediately re-interrupts before the node runs — the node never sees the answer. The second invoke(None) is what actually executes intake_node or approval_node.
AgentState extends LangGraph's MessagesState (which provides the messages field with built-in append semantics) and adds typed fields for every piece of data the agent needs:
class AgentState(MessagesState):
# Flow control
user_id: str
current_node: str # routing signal — not the LangGraph node name
intake_step: int # which onboarding question we're on (0–5)
# User profile (filled by intake_node)
savings_total: float
emergency_fund_floor: float
investable_surplus: float
monthly_investment_budget: float
risk_profile: str # "conservative" | "balanced" | "growth"
tax_bracket: float # estimated marginal rate (0.14 – 0.42)
is_married: bool # affects Sparerpauschbetrag allowance
# Portfolio (filled by upload_node + analysis_node)
holdings: list[HoldingState]
total_invested: float
total_current_value: float
total_unrealised_gain: float
allocation: dict
# Tax summary (filled by analysis_node)
tax: TaxState
# Strategy (filled by plan_node + approval_node)
approved_strategy: dict # plan_text + data (allocation, blueprint_id, version)
monthly_split: dictPhase 2 added: blueprint parameters (horizon_years, prefers_simplicity,
em_thematic_comfort), intake bookkeeping (intake_plan, intake_reasked),
router output (routed_query, routed_domains, wants_projection) and the
rolling history summary (summary, summary_msg_count) — see agent/state.py.
current_node is a routing signal written by each node to tell the conditional edge functions where to go next. It is not the LangGraph internal node name — it is a field in the application state used by route_after_intake and route_after_approval.
| Node | LLM? | Reads | Writes |
|---|---|---|---|
intake_node |
AGENT_ROUTER_MODEL (question phrasing + answer extraction; canned/regex fallbacks) |
intake_step, intake_plan, messages, UserProfile knowns |
profile fields incl. horizon_years, prefers_simplicity, em_thematic_comfort; next question |
upload_node |
No | Django DB (Holding, UserProfile) |
holdings list in AgentState |
fetch_prices_node |
No | holdings |
prices |
analysis_node |
No | holdings, prices |
holdings (with live prices), tax (TaxState), totals |
plan_node |
AGENT_MODEL (strict JSON schema) |
profile params → eligible blueprints | approved_strategy (prose + allocation + blueprint_id), conformance-checked |
qa_node |
AGENT_MODEL (intent, strict JSON schema) |
last user message | current_node (save / adjust / answer) |
rewrite_route_node |
AGENT_ROUTER_MODEL (strict JSON schema) |
conversation tail | routed_query, routed_domains, wants_projection |
retrieve_node |
No (embeddings only) | routed_query; active blueprint sources preferred only when the router flags the question as being about the user's own plan |
retrieved_context |
simulate_node |
No | wants_projection, user numbers |
projection_context |
answer_node |
AGENT_MODEL |
windowed history + summary + tool context | reply (cite-or-say-so framing, fabricated citations stripped), summary |
approval_node |
No | approved_strategy |
UserProfile (saved strategy), confirmation |
digest_node |
AGENT_MODEL |
Full AgentState context | New digest message appended to messages |
digest_node is called directly by digest/views.py, bypassing the graph routing entirely. It is also registered as a graph node so it can be called in future automated flows.
from psycopg_pool import ConnectionPool
from psycopg.rows import dict_row
from langgraph.checkpoint.postgres import PostgresSaver
pool = ConnectionPool(
conninfo=settings.LANGGRAPH_DB_URL,
max_size=20,
kwargs={"autocommit": True, "prepare_threshold": 0, "row_factory": dict_row},
open=True,
)
memory = PostgresSaver(pool)
memory.setup() # idempotent — creates checkpoint tables on first runA ConnectionPool is used (not a single connection) because Django's threaded request handling requires concurrent database access. The three kwargs are required by PostgresSaver:
autocommit=True— LangGraph manages its own transaction boundaries; a connection pool in autocommit mode lets it do so without conflict.prepare_threshold=0— disables psycopg3's server-side prepared statements, which are incompatible with PgBouncer-style connection poolers.row_factory=dict_row—PostgresSaverexpects rows as dicts, not tuples.
LANGGRAPH_DB_URL defaults to the same Postgres instance as the Django ORM (see investbuddy/settings.py). Override it with the LANGGRAPH_DB_URL environment variable to point the checkpointer at a different database.
All calculations are in agent/tax_engine.py. No external service is called. Each constant has a source citation.
The flat capital gains tax rate in Germany:
- 25% Abgeltungsteuer + 5.5% Solidaritätszuschlag surcharge
- Effective rate: 26.375% on gains
Partial tax exemption on investment funds. Rationale: fund companies already pay corporate tax on their income before it reaches investors, so the state reduces the investor's tax to avoid double taxation.
- Equity ETFs (≥51% stocks): 30% of gains exempt → effective rate ~18.46%
- Bond ETFs (<25% equities): 0% exempt → full 26.375%
- Individual stocks: 0% exempt → full 26.375%
This is why accumulating ETFs are tax-efficient for long-term investors in Germany.
An annual advance tax on accumulating (thesaurierend) ETFs. Because acc. ETFs never pay dividends, the tax authority collects a proxy tax each January based on a theoretical return:
Basisertrag = fund_value_jan1 × Basiszins × 0.70
Vorabpauschale = max(0, Basisertrag − distributions_paid) × (1 − 0.30 Teilfreistellung)
Tax = Vorabpauschale × 26.375%
Basiszins is set annually by the Deutsche Bundesbank / BMF. The 2026 rate is 3.20%. Only accumulating ETFs (etf_acc) attract this tax — distributing ETFs (etf_dist) are taxed when dividends are paid, so no advance tax applies.
Annual tax-free allowance on capital income:
- Single: €1,000/year
- Married (filing jointly): €2,000/year
Applied at the portfolio level. InvestBuddy tracks remaining allowance in TaxState.sparerpauschbetrag_remaining and uses it to shade the tax estimates shown in the portfolio view.
If you leave Germany with a portfolio whose total acquisition cost exceeds €500,000, the departure is treated as a deemed disposal — you owe capital gains tax as if you sold everything on the day you left, even though you haven't.
InvestBuddy flags this as a warning when total_invested > €500,000. This rule applies from 1 January 2025.
Owns the four Django models. Data here persists independently of the agent conversation.
Views:
GET /portfolio/— fetches live prices via yfinance, updates holding values, renders the overviewPOST /portfolio/manual/— adds a single holding by formPOST /portfolio/upload/csv/— parses a CSV, upserts holdings viaupdate_or_createGET /portfolio/holdings/— HTMX partial: holdings tableGET /portfolio/tax/— HTMX partial: tax summary panel
A thin bridge between HTMX HTTP requests and the LangGraph graph. Contains no business logic.
Views:
GET /chat/— loads message history from the latest checkpoint; bootstraps the graph (two invokes) if no messages exist yetPOST /chat/message/— double-invoke pattern; returns only the new messages as an HTML partial viachat/message.htmlPOST /chat/reset/— deletes LangGraph checkpoint rows for the thread, preserves Django DB, re-bootstraps
Views:
GET /digest/— scans the message history for the last assistant message that looks like a digest (heuristic: length > 200, contains "portfolio", "tax", "allowance", or "educational")POST /digest/generate/— callsdigest_node(state.values)directly, returns the result as an HTML partial with characters escaped
# Install dependencies (creates .venv automatically)
uv sync
# Configure environment
cp .env.example .env # fill in OPENAI_API_KEY and DJANGO_SECRET_KEY
# Start the Postgres container (host port 5433)
just db-up
# Run Django migrations (also creates LangGraph checkpoint tables via memory.setup())
just migrate
# Collect static files
just static
# Start the dev server
just serverOpen http://localhost:8000 — lands on /portfolio/ with the chat panel open on the left (the /chat/ page now redirects there; chat lives in the collapsible panel on every page).
To stop the database container: just db-down
| Variable | Required | Description |
|---|---|---|
OPENAI_API_KEY |
Yes | Used in plan_node, answer_node, digest_node, and the rag app (embeddings + query translation) |
AGENT_MODEL |
No | OpenAI model for plan_node and digest_node, called via the Chat Completions API. Defaults to gpt-5.4. Must be a chat-completions-compatible model — *-pro models (e.g. gpt-5.4-pro) are not supported here as they require the Responses API |
RAG_EMBEDDING_MODEL |
No | OpenAI embedding model for the knowledge base. Defaults to text-embedding-3-small |
RAG_TRANSLATION_MODEL |
No | OpenAI model used to translate queries into a knowledge base's language (also used for the borderline relevance check). Defaults to gpt-4o-mini |
AGENT_ROUTER_MODEL |
No | Cheap model for the per-question rewrite/routing call (standalone query + which tools to run). Defaults to gpt-4o-mini |
RAG_DEFAULT_QUERY_LANGUAGE |
No | Language user queries arrive in; bases in this language skip translation. Defaults to en |
DJANGO_SECRET_KEY |
Yes (prod) | Django session signing key |
DEBUG |
No | Defaults to True |
POSTGRES_DB |
No | Database name. Defaults to investbuddy |
POSTGRES_USER |
No | Database user. Defaults to investbuddy |
POSTGRES_PASSWORD |
No | Database password. Defaults to investbuddy |
POSTGRES_HOST |
No | Database host. Defaults to localhost |
POSTGRES_PORT |
No | Database port. Defaults to 5433 (Docker maps 5433→5432 to avoid clashing with a local Postgres) |
LANGGRAPH_DB_URL |
No | Full DSN for the LangGraph checkpointer. Defaults to the same Postgres instance as the ORM |
DJANGO_LOG_LEVEL |
No | Level for Django's own loggers. Defaults to INFO |
APP_LOG_LEVEL |
No | Level for the app loggers (agent, rag, …). Defaults to DEBUG when DEBUG=True, else INFO |
LANGSMITH_TRACING |
No | true to stream traces to LangSmith. Defaults to false |
LANGSMITH_API_KEY |
Only if tracing | LangSmith API key (lsv2_...) |
LANGSMITH_PROJECT |
No | LangSmith project name. Defaults to kyron-investbuddy |
just test132 tests covering: tax engine functions, price service (mocked yfinance), node helpers, routing logic, all portfolio views, chat view double-invoke logic, and digest view HTML escaping.
uv run python manage.py rag_eval # retrieval hit@4 + MRR per domain (cheap)
uv run python manage.py rag_eval --rewrite # retrieval through the production rewrite router
uv run python manage.py rag_eval --answers # + fact checks, citation validity, LLM-judge groundedness
uv run python manage.py rag_eval --plans 3 # + plan conformance baselineThe golden question set lives in rag/evals/golden.yaml; results are tracked in
the scoreboard of docs/rag-agent-improvement-plan.md.
Two independent layers: logging (always on, local) and LangSmith tracing (opt-in, remote). Logging answers "what happened on this server"; LangSmith answers "what did the agent do on this run, step by step".
Logging is configured in the LOGGING dict in kyron/settings.py. There are two
formatters, two handlers, and one logger per app.
| Piece | Value |
|---|---|
| Console handler | concise format (`LEVEL logger |
| File handler | verbose format (timestamp, level, logger:lineno, func()), written to logs/kyron.log |
| Rotation | RotatingFileHandler, 5 MB per file, 5 backups (kyron.log.1 … kyron.log.5) |
| App loggers | agent, rag, portfolio, chat, digest, accounts |
logs/ is gitignored and created automatically on startup. Tune verbosity
without touching code via the env vars:
# .env
DJANGO_LOG_LEVEL=INFO # Django's own loggers (requests, server, ORM)
APP_LOG_LEVEL=DEBUG # the agent/rag/portfolio/... loggersUsing a logger in code. Get a logger named after the app (so it inherits the
configured handlers and level) and log at the appropriate level — never print:
import logging
logger = logging.getLogger("agent.nodes") # or __name__ inside an app module
logger.debug("fetch_prices: resolved %d/%d tickers", hits, total)
logger.info("answer_node: generating reply (strategy_saved=%s)", saved)
logger.warning("retrieve_tax_context: retrieval failed for %r", query, exc_info=True)Pass values as %s args (not f-strings) so formatting is skipped when the level
is disabled, and add exc_info=True inside except blocks to capture the
traceback. The agent nodes and tools already log their key steps this way.
Watching the log:
tail -f logs/kyron.logLangSmith records every agent run as a tree of spans. With tracing on you get, per chat turn:
- the graph run — each LangGraph node (
intake,qa,plan,answer, …) as a span, with the state in/out; - tool spans —
fetch_prices,retrieve_tax_context,compute_projection, because the tools inagent/tools.pyare decorated with@traceable; - LLM spans — the raw OpenAI calls, because the client is wrapped with
instrument_openai()(latency, tokens, prompt, completion).
Enable it — add a key and flip the switch in .env:
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=lsv2_...
LANGSMITH_PROJECT=kyron-investbuddy # optional; this is the defaultThen restart the server and run a chat turn. Traces appear under your project at https://smith.langchain.com.
How it's wired (agent/observability.py + kyron/settings.py):
settings.pynormalisesLANGSMITH_*(or legacyLANGCHAIN_*) env vars into theLANGCHAIN_*vars LangGraph/LangSmith read at runtime — beforeagent.graphis imported. Tracing turns on only when bothLANGSMITH_TRACING=trueand a key are present; otherwise it is forced off so a stale env var can't silently start sending data.observability.pyexposestraceable(...),instrument_openai(client)andtracing_enabled(). All three are no-ops when the switch is off or thelangsmithpackage is missing, so nothing breaks and no network calls are made when tracing is disabled — the default.
Tracing a new function. Decorate it; choose a run_type so it renders with
the right icon:
from agent.observability import traceable
@traceable(run_type="tool", name="my_new_tool") # "tool" | "retriever" | "chain" | "llm"
def my_new_tool(arg: str) -> str:
...Promoting the tool to a graph node (a wrapper in agent/nodes.py registered in
agent/graph.py) additionally makes it show up as its own step on the graph
trace, not just an inline span.
One Postgres instance, two logical areas. The Django ORM tables and the LangGraph checkpoint tables share the same PostgreSQL database but are kept logically separate. Mixing them at the schema level would mean the conversation checkpoint format leaks into the relational schema, and resetting a conversation would risk deleting portfolio data. The contract is explicit: Django owns facts, LangGraph owns conversation flow. A single Postgres instance simplifies local setup (one Docker container, one connection string) without sacrificing the separation.
Blueprints decide, RAG explains. Strategy methodology lives in a versioned registry (agent/strategy_blueprints.py): each blueprint pairs curated sources with deterministic eligibility conditions and a machine-checkable allocation template. Plan generation never retrieves — the LLM only picks among pre-filtered eligible blueprints and personalises prose, then the output is conformance-validated. RAG serves Q&A (scoped to the active blueprint's sources first) and explanation, not decision-making. References are rendered from the registry by code, so a plan citation can never be fabricated.
Deterministic core, LLM at the edges. All tax calculations, eligibility filtering, conformance validation and projections are deterministic Python — auditable and traceable to statute or registry. LLM calls are confined to: plan personalisation and Q&A answers (AGENT_MODEL, strict JSON schemas where structure matters), and small routing/phrasing/extraction calls (AGENT_ROUTER_MODEL), each with a deterministic fallback so the graph keeps working when a call fails.
Chat Completions API, not Responses. All LLM calls go through OpenAI's chat.completions endpoint. This is a deliberate choice: it is stable, widely documented, and every chat-tier model (including the current gpt-5.4) works as a drop-in via AGENT_MODEL. The trade-off is that reasoning-only flagships such as gpt-5.4-pro are unavailable, because they require the newer Responses API. Adopting a *-pro model would mean migrating the call sites in agent/nodes.py from client.chat.completions.create(...) (reading choices[0].message.content) to client.responses.create(...) (reading output_text), so it is left for a future change if deeper reasoning is ever needed.
interrupt_before, not interrupt_after. The interrupts are placed before intake and approval so that when the graph resumes, the node runs with the user's input already in state. If the interrupt were after the node, the node would run before the user had answered, producing empty or stale output.
WhiteNoise for static files. No nginx or CDN required in development or production. WhiteNoise serves compressed static files directly from Django. The runserver_nostatic app replaces Django's built-in dev static server so the same WhiteNoise path is used in both environments.
HTMX from local static file. The CDN URL was unreliable in the preview browser. HTMX is downloaded to static/js/htmx.min.js and served by WhiteNoise. This also means the app works fully offline once the server is running.
update_or_create for CSV import. The portfolio CSV upload uses update_or_create (not get_or_create) because Holding.units and Holding.avg_purchase_price are required fields with no default. get_or_create would attempt to INSERT without those values and fail on a fresh database. update_or_create passes them in the defaults dict, which is used both for creation and for updating an existing row.
- Single-user demo.
USER_ID = "demo"andTHREAD_CONFIGare module-level constants. There is no authentication or multi-tenancy. - EIMI.DE (iShares EM IMI) returns €0 from yfinance. This XETRA ticker is not reliably resolved. Use
EIMI.L(London) orIS3N.DEas an alternative. - Tax calculations are estimates. Church tax (Kirchensteuer), loss carryforward offsets, and foreign tax credits are not modelled. The Sparerpauschbetrag is not automatically deducted from individual position tax figures — it is tracked at the portfolio level only.
- Vorabpauschale uses a fixed Basiszins. The 2026 rate (3.20%) is hardcoded. Update
BASISZINS_2026inagent/tax_engine.pyeach January. - Approved strategies persist in the
Strategymodel, intake answers onUserProfile. When the user approves a plan in chat,approval_nodewrites it to the Django DB; saved strategies and invested portfolios survive conversation resets and can be switched from the Portfolio page. The conversation flow itself still lives in the LangGraph checkpoint.