Map All of Finance as a Knowledge Graph
Build a typed, queryable knowledge graph of finance in Python.
Introduction
30 Second Summary
Finance is full of names that sound important but float around without any visible structure connecting them. You can read about discounted cash flows, CFA charters, and regulatory registrations without ever seeing how they fit together.
In this project, you will encode the entire field of finance as a typed knowledge graph in Python. You will write 62 nodes by hand, connect them with typed edges, then query the graph to answer structural questions cold.
What You'll Build
When you run the test suite, it scores your understanding pass/fail: the value-chain query traces how a forecast cash flow becomes a share price, and the study-scope query proves which activities you can practice without registration.
By the end of this project, you'll have:
- A queryable finance graph with 62 typed nodes and 8 edge types that answers "how does a cash flow become a share price" and "which route lets me manage money for others" by running a command, not reciting from memory.
- A scored test suite of 4 queries with expected answers written before the queries run. Every wrong answer names the exact node or edge that was missing.
- A Mermaid topology diagram you can browse in any viewer and a 20-card Markdown deck for retrieval practice, both exported from the same source file.
- Secret Mission: Wire an agent orchestrator pattern so each pipeline stage is a callable agent, extensible to LLM-powered extractors.
Are there any prerequisites?
You need Python installed (3.10 or later), a flat-rate AI agent (Claude Code, Cursor, or similar), and a GitHub account. No finance background is required. Comfort reading Python is enough.
Before We Start
Before you write a single line of code, take a moment to commit to what you are building and why.
Encoding finance as a typed graph forces you to place every concept into a queryable relationship with other concepts. Reading about markets gives you names. Building the graph proves you understand the structure between them.
Set Up the Graph Workspace
Before you write a single node or edge, every tool in the pipeline needs to work. A missing import discovered mid-build costs more than a few minutes of verification up front.
In this step you will create the project directory, install the three Python packages the pipeline depends on, initialize a git repository, and scaffold the empty files that later steps fill in. By the end, running one command proves the entire toolchain is live.
In this step, get ready to:
- Set up the project directory with a virtual environment and a git repository pushed to GitHub.
- Install the three pipeline dependencies and verify they import.
- Create placeholder files for all modules and tests.
Verify Python and create the project directory
The pipeline needs Python 3.10 or later because pytest requires it. Let's confirm your version before creating anything.
- Check your Python version by running this command:
python3 --version
✔️ I see version 3.10 or higher
You are good to go. Any version from 3.10 upward works for this project.
ⓧ I see an older version
Your system Python is below 3.10. On macOS the default python3 may resolve to an older version even after you install a newer one.
- Install Python 3.10 or later from python.org.
- After installing, run python3.12 --version (or whichever minor version you installed) to confirm the new binary is on your PATH.
- Use that binary name (python3.12) in place of python3 for the rest of this project, or use pyenv to set a local version.
ⓧ Command not found
Python is not installed on this machine.
- Download and install Python 3.12 (or later) from python.org.
- On Windows, check the box that says Add python.exe to PATH during installation.
- After installing, close and reopen your terminal, then run python3 --version again.
Now let's create the project directory on your Desktop so you can find it easily.
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir finance-map
cd finance-map
mkdir src tests docs exports
- Confirm the directories exist by running:
ls
You should see docs, exports, src, and tests listed.
Next, create a virtual environment and activate it so all packages install into this project only.
- Create and activate the virtual environment by running:
macOS
python3 -m venv .venv
source .venv/bin/activate
Windows
python -m venv .venv
.venv\Scripts\activate
- Verify the virtual environment is active by running:
macOS
which python3
Windows
where python
You should see a path containing .venv, confirming the virtual environment is active.
Not seeing .venv in the path?
- Make sure you ran the activate command (not just the python3 -m venv line).
- On macOS/Linux, check you used source before the path.
- On Windows PowerShell, you may need to allow script execution first by running Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned.
Help me activate my Python virtual environment.
Now initialize a Git repository and push it to a private GitHub remote.
- Initialize the repository by running:
git init
git add .
git commit -m "Initial commit: project structure"
- Create a new private repository on GitHub named finance-map. Do not add a README or .gitignore during creation.
- Push your local repo to the new remote by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/finance-map.git"]]
git push -u origin main
- Verify the remote is set by running:
git remote -v
You should see your GitHub URL listed for both fetch and push.
Push rejected or branch name mismatch?
- If Git created a master branch instead of main, rename it first: git branch -M main.
- If authentication fails, make sure you are using a personal access token or SSH key for GitHub.
Help me push my local git repo to GitHub.
Finally, create a Linear project to track your deliverables as tickets.
- Sign in to linear.app (create a free account if you do not have one).
- Open the Projects view in the sidebar.
- Click New Project and name it Finance Map.
- Create six issues (tickets) inside the project, one for each deliverable:
- Schema and source file (graph.yaml).
- Integrity checker.
- Query pack (4 queries with fixtures).
- Mermaid topology export.
- Card deck export.
- Documentation pages and presentation.
Why track tickets for a solo project?
Each ticket becomes a branch. Each branch becomes a pull request that closes the ticket when it merges. This gives you a commit history that proves the build order, not just the final state.
Install the pipeline dependencies
The pipeline uses three packages: NetworkX for the graph data structure, PyYAML to parse the source file, and pytest to score your queries.
- Install all three by running:
pip install networkx pyyaml pytest
What does this install?
- networkx provides the DiGraph class: a directed graph where nodes and edges carry typed attributes.
- pyyaml provides yaml.safe_load() to read YAML without executing arbitrary code.
- pytest is the test runner that scores your query fixtures as pass/fail.
- Verify all three import correctly by running:
python -c "import networkx; import yaml; import pytest; print('imports ok')"
You should see imports ok printed with no errors.
Seeing ModuleNotFoundError?
- Make sure your virtual environment is active (your prompt should show (.venv)).
- If you used python3 to create the venv, try python3 -c instead of python -c.
- Re-run pip install networkx pyyaml pytest inside the activated environment.
Help me fix a ModuleNotFoundError after pip install.
Create the placeholder files
Each module and test file needs to exist before you can import across them. Creating them now means later steps only add content, never wrestle with missing-file errors.
- Create all placeholder files by running:
macOS
touch src/__init__.py src/schema.py src/builder.py
touch src/integrity.py src/queries.py src/export.py
touch tests/__init__.py tests/test_integrity.py tests/test_queries.py
touch graph.yaml
Windows
New-Item src/__init__.py, src/schema.py, src/builder.py -Force
New-Item src/integrity.py, src/queries.py, src/export.py -Force
New-Item tests/__init__.py, tests/test_integrity.py, tests/test_queries.py -Force
New-Item graph.yaml -Force
- Confirm the files exist by running:
ls src/ tests/ graph.yaml
You should see __init__.py, schema.py, builder.py, integrity.py, queries.py, and export.py inside src/. You should see __init__.py, test_integrity.py, and test_queries.py inside tests/. You should also see graph.yaml at the project root.
Missing a file?
- Check that you ran the commands from inside the finance-map/ directory, not from src/ or tests/.
- On Windows, make sure you included the -Force flag.
Now let's run the full verification that proves the workspace is ready.
- Before you run the next command, predict: will all three imports succeed, or will one fail?
- Run the final check:
python -c "import networkx; import yaml; import pytest; print('ready')"
You should see ready printed with no tracebacks. That single word proves Python, all three libraries, and the virtual environment are wired correctly.
Your workspace is live. Every dependency imports, every directory is in place, and the repo is tracked. Next up, you will define the schema vocabulary and write the source file that encodes finance as a typed graph.
Design the Schema and Write the Source File
Your workspace is ready. Now comes the real work: encoding the structure of finance into a typed graph.
Every node you write forces you to place a finance concept into a typed relationship with other concepts. An untyped edge is a feeling, not a claim anyone can query. The source file IS the learning. You cannot fake structure.
In this step, get ready to:
- Define a closed vocabulary of 10 node types and 8 edge types in src/schema.py.
- Write graph.yaml by hand with 62 typed nodes and their edges.
- Build the graph loader in src/builder.py that validates and constructs a NetworkX DiGraph.
Define the type vocabulary in schema.py
A knowledge graph without a fixed vocabulary drifts into ad-hoc naming. The schema defines exactly which node types and edge types are legal. Anything else gets rejected.
- Open src/schema.py in your editor and replace the empty file with the type vocabulary:
"""Schema constants and validation for the finance knowledge graph."""
NODE_TYPES = frozenset([
"domain",
"concept",
"quantity",
"instrument",
"method",
"activity",
"regulatory-item",
"route",
"credential",
"build",
])
EDGE_TYPES = frozenset([
"precedes",
"belongs-to",
"flows-to",
"permitted-as-study",
"requires-registration",
"requires",
"prices",
"exercised-by",
])
What does this code do?
NODE_TYPES lists the 10 categories a node can belong to. EDGE_TYPES lists the 8 relationships allowed between nodes.
Using frozenset means these vocabularies are immutable. Nothing in the codebase can accidentally add a new type at runtime. The vocabulary is closed by design.
- Below the EDGE_TYPES definition, add the error class and validation functions:
class SchemaError(Exception):
"""Raised when a node or edge type is not in the vocabulary."""
pass
def validate_node_type(node_type: str) -> None:
if node_type not in NODE_TYPES:
raise SchemaError(f"Invalid node type: '{node_type}'. Allowed: {sorted(NODE_TYPES)}")
def validate_edge_type(edge_type: str) -> None:
if edge_type not in EDGE_TYPES:
raise SchemaError(f"Invalid edge type: '{edge_type}'. Allowed: {sorted(EDGE_TYPES)}")
What do the validators do?
SchemaError is a custom exception that fires whenever a node or edge type is not in the vocabulary. The two validator functions check a single type string against the frozen sets and raise immediately if it does not belong.
This means the system rejects invalid structure at parse time. A typo in graph.yaml never makes it into the graph.
- Save src/schema.py.
- Verify the module imports cleanly by running:
python -c "from src.schema import NODE_TYPES, EDGE_TYPES, SchemaError; print(len(NODE_TYPES), 'node types,', len(EDGE_TYPES), 'edge types')"
You should see 10 node types, 8 edge types printed with no errors.
Seeing a ModuleNotFoundError?
Make sure you are running the command from the finance-map/ project root, not from inside src/. Python needs the src package to be a subdirectory of the current working directory.
Also confirm that src/__init__.py exists (even if empty). Without it, Python does not recognize src as a package.
help me debug this ModuleNotFoundError when importing src.schema
Write graph.yaml with 62 typed nodes
The YAML source file is your primary learning artifact. Every node you add forces a decision: what type is this concept, and what typed relationship connects it to the rest of the structure?
The file has two top-level keys: nodes (a list of objects with id, type, and optional landmark) and edges (a list of objects with source, target, type).
- Open graph.yaml and start with this structure showing one node of each key type and a few edges:
nodes:
- id: corporate_finance
type: domain
- id: discount_rate
type: concept
landmark: "turns future cash into present value"
- id: free_cash_flow
type: quantity
- id: equity
type: instrument
- id: dcf
type: method
- id: portfolio_rebalancing
type: activity
- id: advisers_act
type: regulatory-item
- id: charter_route
type: route
- id: cfa_charter
type: credential
- id: dcf_model
type: build
edges:
- source: free_cash_flow
target: discount_rate
type: flows-to
- source: dcf
target: corporate_finance
type: belongs-to
What is the landmark attribute?
A landmark is an optional one-line description on any node that anchors it to a concrete meaning. Not every node needs one. Use landmarks on nodes whose purpose is not obvious from their ID alone.
Now populate the full file. Your complete inventory is:
- 11 domains (one per CFA Level I topic area, plus corporate_finance).
- 6 concepts including discount_rate, net_debt_bridge, and study_scope.
- 5 quantities forming the value chain (free_cash_flow through per_share_value).
- 5 instruments, 4 methods, 8 activities (4 permitted as study, 4 requiring registration).
- 4 regulatory items, 4 routes (academic_doctorate, charter_route, quant_route, search_fund), 4 credentials, 11 builds.
- Use your AI agent to help populate the full 62 nodes and their edges. Direct the agent with the structure above and the inventory list. Every node must have at least one edge. All 8 edge types must appear.
- The value chain must wire free_cash_flow to per_share_value via flows-to edges through both an unlevered route (5 edges, passing through net_debt) and a levered route (3 edges).
- Map every CFA Level I topic area to at least one domain node.
- Save graph.yaml when done.
- Verify the YAML parses without syntax errors by running:
python -c "import yaml; data = yaml.safe_load(open('graph.yaml')); print(len(data['nodes']), 'nodes,', len(data['edges']), 'edges in YAML')"
You should see 62 nodes and your edge count printed. If the count is not 62, add or remove nodes until the inventory matches.
Seeing a yaml.scanner.ScannerError?
YAML is whitespace-sensitive. The most common error is mixing tabs with spaces. Ensure your editor uses spaces only (2-space indent is standard for YAML).
Also check that any landmark values containing colons or special characters are wrapped in quotes.
help me fix a YAML parsing error in my graph.yaml file
Why YAML over a custom DSL?
Alternative considered: a Python dict literal or TOML file. YAML wins because it is hand-editable without import statements and readable without running code.
Tradeoff accepted: YAML is more whitespace-sensitive. Reversal condition: if the graph exceeds 200 nodes and indentation errors become the primary failure mode, migrate to a validated JSON schema with IDE completion.
Why NetworkX over a graph database?
Alternative considered: Neo4j with Cypher queries. NetworkX wins because there is no server, no Docker, and no connection string. The graph is ~62 nodes. The tradeoff is losing native path-query syntax.
Reversal condition: if later builds need concurrent multi-user writes or the graph exceeds 10,000 edges and query latency matters, move to a graph database.
Build the graph loader in builder.py
The builder reads the YAML, validates every type against the schema vocabulary, and constructs a networkx.DiGraph with typed attributes on both nodes and edges.
- Open src/builder.py and add the imports, the function signature, and the node-processing loop:
"""Parse the YAML source file and build a NetworkX DiGraph."""
from pathlib import Path
import networkx as nx
import yaml
from src.schema import SchemaError, validate_edge_type, validate_node_type
def load_graph(path: str | Path) -> nx.DiGraph:
"""Load graph.yaml and return a validated NetworkX DiGraph."""
path = Path(path)
with path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data or "nodes" not in data or "edges" not in data:
raise SchemaError("Source file must have top-level 'nodes' and 'edges' keys.")
G = nx.DiGraph()
for node in data["nodes"]:
node_id = node["id"]
node_type = node["type"]
validate_node_type(node_type)
attrs = {"type": node_type}
if "landmark" in node:
attrs["landmark"] = node["landmark"]
G.add_node(node_id, **attrs)
What does this code do?
The function opens the YAML file and immediately checks that both nodes and edges keys exist. If not, it raises SchemaError before going further.
For each node, it validates the type against the frozen set, then adds the node to the DiGraph with its type and optional landmark as attributes. Any invalid type raises immediately.
- Below the G.add_node(node_id, **attrs) line, add the edge-processing loop and the return statement:
for edge in data["edges"]:
source = edge["source"]
target = edge["target"]
edge_type = edge["type"]
validate_edge_type(edge_type)
if source not in G:
raise SchemaError(f"Edge source '{source}' is not a declared node.")
if target not in G:
raise SchemaError(f"Edge target '{target}' is not a declared node.")
G.add_edge(source, target, type=edge_type)
return G
What does the edge loop do?
For each edge, it validates the type and also confirms that both source and target reference nodes that already exist in the graph. A dangling reference (a typo in a node ID) gets caught here rather than silently creating an untyped ghost node.
- Save src/builder.py.
Before you run the final check, predict what you expect to see. Will the builder report 62 nodes, or will it raise a SchemaError naming a bad type or a dangling edge?
- Run the builder against your source file:
python -c "from src.builder import load_graph; G = load_graph('graph.yaml'); print(len(G.nodes()), 'nodes', len(G.edges()), 'edges')"
You should see 62 nodes and your edge count printed with no SchemaError. If the builder raises, it names the exact node or edge that violated the vocabulary.
Seeing a SchemaError?
This is the validator doing its job. Read the error message. It names the invalid type or the dangling node ID.
Common causes: a node type field with a typo (e.g. regulatory_item with an underscore instead of regulatory-item with a hyphen), or an edge referencing a node ID that does not exist in the nodes list.
help me fix the SchemaError in my graph.yaml
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/schema.py and src/builder.py. Confirm graph.yaml reports 62 nodes.
ⓧ I'd like to double check the full code
Your src/schema.py should contain, in order:
- The module docstring.
- NODE_TYPES as a frozenset with 10 string entries.
- EDGE_TYPES as a frozenset with 8 string entries.
- The SchemaError exception class.
- validate_node_type and validate_edge_type functions.
Your src/builder.py should contain, in order:
- The module docstring and imports (Path, networkx, yaml, and the schema imports).
- The load_graph function that reads YAML, validates top-level keys, loops through nodes validating each type and adding to the DiGraph, loops through edges validating type and source/target existence, then returns G.
Your graph.yaml should have 62 nodes (11 domains, 6 concepts, 5 quantities, 5 instruments, 4 methods, 8 activities, 4 regulatory-items, 4 routes, 4 credentials, 11 builds) and edges covering all 8 edge types. Every node must have at least one edge.
Your schema, source file, and builder are locked in. The graph has 62 typed nodes and a validated set of edges. Next up, you will build the integrity checker that catches structural errors the way a compiler catches type errors.
Build the Graph and Enforce Integrity
Your graph has 62 nodes and dozens of edges, but right now a missing connection is invisible. If you forget one edge, no error fires. A query just silently returns nothing, and you never know your structure was wrong.
The integrity checker catches structural errors the same way a compiler catches type errors. It enforces three rules: zero orphan nodes, zero edges outside the vocabulary, and full reachability across the graph.
In this step, get ready to:
- Implement three integrity checks in src/integrity.py.
- Write pytest tests with a session-scoped fixture and failure messages that name the offending nodes.
- Deliberately break the graph to confirm the checker catches the violation.
Implement the three integrity checks
Each check answers one structural question. Can a node exist with no connections? Can an edge carry a type outside the 8 you defined? Can a node become unreachable from the rest of the graph?
- Open src/integrity.py and replace the placeholder content with these three check functions:
"""Integrity checks: no orphans, vocabulary-only edges, full reachability."""
import networkx as nx
def check_no_orphans(G: nx.DiGraph) -> list[str]:
"""Return node IDs with degree 0 (no edges at all)."""
return [n for n in G.nodes() if G.degree(n) == 0]
def check_edge_vocabulary(G: nx.DiGraph, allowed: frozenset[str]) -> list[tuple]:
"""Return edges whose type is not in the allowed set."""
violations = []
for u, v, data in G.edges(data=True):
if data.get("type") not in allowed:
violations.append((u, v, data.get("type")))
return violations
def check_reachability(G: nx.DiGraph) -> list[str]:
"""Return nodes not reachable from the rest (disconnected components)."""
undirected = G.to_undirected()
if nx.is_connected(undirected):
return []
components = list(nx.connected_components(undirected))
largest = max(components, key=len)
unreachable = []
for comp in components:
if comp != largest:
unreachable.extend(comp)
return sorted(unreachable)
What does this code do?
Each function takes a NetworkX DiGraph and returns a list of violations. An empty list means the check passed.
- check_no_orphans finds any node with degree 0 (no incoming or outgoing edges). A node with no edges is dead weight that no query can reach.
- check_edge_vocabulary flags any edge whose type is not in your 8 allowed types. This catches typos and invented relationships.
- check_reachability converts the directed graph to undirected and checks connectivity. Any node in a disconnected component is reported as unreachable.
- Save src/integrity.py.
- Verify the module loads by running this quick import check:
python -c "from src.integrity import check_no_orphans, check_edge_vocabulary, check_reachability; print('integrity module loaded')"
You should see integrity module loaded printed with no errors.
Seeing a ModuleNotFoundError?
- Make sure you are running the command from the finance-map/ project root, not from inside the src/ directory.
- Confirm your virtual environment is active. You should see (venv) or similar in your terminal prompt.
- Check that src/__init__.py exists. Python needs this file to treat src/ as a package.
Help me debug my integrity module import error.
Write the pytest integrity tests
The checks return violations, but you need a test harness that loads the graph once and asserts each check returns an empty list. When a check fails, the failure message should name exactly which node or edge broke.
- Open tests/test_integrity.py and replace its placeholder content with the following test suite:
"""Integrity tests: orphans, edge vocabulary, reachability."""
import pytest
from src.builder import load_graph
from src.integrity import check_edge_vocabulary, check_no_orphans, check_reachability
from src.schema import EDGE_TYPES
@pytest.fixture(scope="session")
def graph():
return load_graph("graph.yaml")
def test_no_orphans(graph):
orphans = check_no_orphans(graph)
assert orphans == [], f"Orphan nodes (degree 0): {orphans}"
def test_edge_vocabulary(graph):
violations = check_edge_vocabulary(graph, EDGE_TYPES)
assert violations == [], f"Edges with invalid types: {violations}"
def test_reachability(graph):
unreachable = check_reachability(graph)
assert unreachable == [], f"Unreachable nodes (disconnected): {unreachable}"
What does this code do?
- The @pytest.fixture(scope="session") decorator loads the graph once for the entire test session rather than rebuilding it per test. This keeps the suite fast.
- Each test function calls one integrity check and asserts it returns an empty list. If the list is not empty, the f-string in the assertion prints the offending nodes or edges by name.
- The EDGE_TYPES frozenset is imported from your schema so the vocabulary check uses the same source of truth the builder uses.
- Save tests/test_integrity.py.
Why fixtures-first?
Writing the expected answer (an empty violations list) before running the query means the test can actually fail. A test you write after seeing the output just confirms what you already observed. Fixtures-first testing forces your graph to meet the spec, not the other way around.
Run the tests and prove the checker catches violations
Before you run this, predict: do you think all three checks will pass on your first try, or will the checker find a structural error in your graph.yaml?
- Run the integrity test suite by running:
pytest tests/test_integrity.py -v
If all three tests pass, you will see three lines marked PASSED in green. If any test fails, pytest prints the exact list of offending nodes or edges.
Seeing test failures?
That means the checker is working. Read the failure message carefully:
- Orphan nodes listed: find those node IDs in graph.yaml and add at least one edge connecting them.
- Invalid edge types listed: check for typos in the type field of your edges. The allowed types are exactly those in EDGE_TYPES.
- Unreachable nodes listed: these nodes sit in a disconnected island. Add an edge connecting them to the main graph.
Fix your graph.yaml and rerun until all three pass green.
Help me fix my graph integrity failures.
Once all three tests pass green, it is time to prove the checker is not just passing by default. You will deliberately introduce a violation and watch the checker name it.
- Open graph.yaml and find any edge that connects to a node you want to isolate.
- Delete or comment out all edges that touch that node. This creates an orphan with degree 0.
- Save graph.yaml.
Before you rerun, predict: which test will fail, and what will the failure message say?
- Rerun the integrity suite by running:
pytest tests/test_integrity.py -v
You should see test_no_orphans fail with a message naming the exact node ID you isolated. The checker caught the violation by name.
- Restore the edge(s) you deleted in graph.yaml so it returns to its valid state.
- Save graph.yaml and rerun to confirm all three tests pass green again:
pytest tests/test_integrity.py -v
All three tests should show PASSED again. Your integrity checker is proven: it catches violations by name and passes only when the graph is structurally sound.
Still seeing a failure after restoring?
- Check that you restored the edge exactly as it was. YAML is whitespace-sensitive, so make sure the indentation matches the surrounding entries.
- If you commented out the edge with #, make sure you removed the comment character from every line of that edge entry.
Help me fix my graph after the deliberate break test.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/integrity.py and tests/test_integrity.py, and that graph.yaml is restored to its valid state.
ⓧ I'd like to double check the full code
Scroll up and confirm you have these two chunks in place, in order:
- src/integrity.py containing check_no_orphans, check_edge_vocabulary, and check_reachability.
- tests/test_integrity.py containing the session-scoped graph fixture and three test functions (test_no_orphans, test_edge_vocabulary, test_reachability).
Your graph now has a safety net. Any future edit to graph.yaml that disconnects a node or introduces a bad edge type will be caught before it can silently break a query. Next up, you will write the queries that answer structural questions about finance cold.
Run the Query Pack Cold
Your integrity checker catches structural errors. Now you need something that catches understanding errors. The queries are the exam.
The key discipline is fixtures-first: you write the expected answer before running the query. A query you interpret afterwards can never fail. A wrong answer tells you which edge in your graph was never connected or which node was mistyped.
In this step, get ready to:
- Implement 4 query functions that answer structural questions about finance.
- Write fixtures-first tests with expected answers declared before the queries run.
- Run the full query pack cold and interpret what a wrong answer means.
Implement the value chain query
The value chain traces how a forecast cash flow becomes a per-share price. This single query encodes the entire DCF valuation path.
- Open src/queries.py and add the module header, import, and the value_chain function:
"""Query pack: value chain, study scope, registered activities, build coverage."""
import networkx as nx
def value_chain(G: nx.DiGraph) -> list[list[str]]:
"""Find all simple paths from free_cash_flow to per_share_value via flows-to edges."""
flows_to_graph = nx.DiGraph()
for u, v, data in G.edges(data=True):
if data.get("type") == "flows-to":
flows_to_graph.add_edge(u, v)
if "free_cash_flow" not in flows_to_graph or "per_share_value" not in flows_to_graph:
return []
paths = list(nx.all_simple_paths(flows_to_graph, "free_cash_flow", "per_share_value"))
return sorted(paths, key=len, reverse=True)
What does this code do?
The function builds a sub-graph containing only flows-to edges. This isolates the valuation chain from all other relationships in the graph.
- It filters the full DiGraph down to edges of type flows-to only.
- It then finds all simple paths from free_cash_flow to per_share_value in that sub-graph.
- The paths are sorted longest-first so the unlevered route (5 edges, through net debt) appears before the levered route (3 edges, direct to equity).
- Save src/queries.py.
- Test the value chain query by running:
python -c "from src.builder import load_graph; from src.queries import value_chain; G = load_graph('graph.yaml'); paths = value_chain(G); print(f'{len(paths)} paths found'); [print(f' length {len(p)-1}: {p}') for p in paths]"
You should see 2 paths: one with length 5 (the unlevered route through net debt) and one with length 3 (the levered route direct to equity value).
Seeing 0 paths found?
- Check that graph.yaml has nodes named exactly free_cash_flow and per_share_value with a connected chain of flows-to edges between them.
- Verify each quantity node in your value chain has an outgoing flows-to edge to the next quantity.
Why are my value chain paths empty?
Add the study scope and registered activities queries
These two queries work together. One returns what you can study freely. The other returns what the law sends to registration. They must never overlap.
- In src/queries.py, add the study_scope and registered_activities functions below value_chain:
def study_scope(G: nx.DiGraph) -> list[str]:
"""Return activity nodes connected to study_scope by permitted-as-study edges."""
activities = []
for u, v, data in G.edges(data=True):
if data.get("type") == "permitted-as-study" and v == "study_scope":
if G.nodes[u].get("type") == "activity":
activities.append(u)
return sorted(activities)
def registered_activities(G: nx.DiGraph) -> list[str]:
"""Return activity nodes that carry a requires-registration edge."""
registered = []
for u, v, data in G.edges(data=True):
if data.get("type") == "requires-registration":
if G.nodes[u].get("type") == "activity":
registered.append(u)
return sorted(set(registered))
What does this code do?
- study_scope finds activity nodes with a permitted-as-study edge pointing at the study_scope concept node. These are safe to practice without registration.
- registered_activities finds activity nodes carrying a requires-registration edge. These are off-limits without authority from a registration.
- Now add the final query, build_coverage, below registered_activities:
def build_coverage(G: nx.DiGraph) -> dict[str, list[str]]:
"""Return landmark nodes, domain nodes, and instrument nodes with no link to a build."""
has_build_link = set()
for u, v, data in G.edges(data=True):
if G.nodes.get(v, {}).get("type") == "build":
has_build_link.add(u)
uncovered_landmarks = []
uncovered_domains = []
uncovered_instruments = []
for node, attrs in G.nodes(data=True):
if attrs.get("landmark") and node not in has_build_link:
uncovered_landmarks.append(node)
if attrs.get("type") == "domain" and node not in has_build_link:
uncovered_domains.append(node)
if attrs.get("type") == "instrument" and node not in has_build_link:
uncovered_instruments.append(node)
return {
"landmarks": sorted(uncovered_landmarks),
"domains": sorted(uncovered_domains),
"instruments": sorted(uncovered_instruments),
}
What does this code do?
This query is a completeness check. It finds any landmark, domain, or instrument node that was collected in the graph but never linked to a build node.
A non-zero count means something was named but never practiced. The count is a regression check on your inventory, not a finding about markets.
- Save src/queries.py.
- Verify the study scope query works by running:
python -c "from src.builder import load_graph; from src.queries import study_scope, registered_activities; G = load_graph('graph.yaml'); print('Study:', study_scope(G)); print('Registered:', registered_activities(G))"
You should see two non-overlapping lists. The study list contains activities you can practice freely. The registered list contains exactly 4 activities that require authority from a registration.
Seeing overlap between the two lists?
If an activity appears in both lists, your graph has both a permitted-as-study and a requires-registration edge on the same node. Remove the incorrect edge from graph.yaml.
Why is an activity appearing in both my study and registered lists?
Write the fixtures-first tests
Fixtures-first means you declare the expected answer before the query runs. If you wrote the assertion after seeing the output, you would be fitting your expectation to whatever the graph happened to return. That test can never fail.
- Open tests/test_queries.py and add the imports and session-scoped fixture:
"""Query tests with fixtures-first: expected answers written before queries run."""
import pytest
from src.builder import load_graph
from src.queries import build_coverage, registered_activities, study_scope, value_chain
@pytest.fixture(scope="session")
def graph():
return load_graph("graph.yaml")
Why a session-scoped fixture?
The fixture loads the graph once and shares it across all tests in the session. Loading YAML and building the DiGraph is not expensive, but session scope guarantees every test runs against the exact same graph state.
- Below the fixture, add the TestValueChain class with 4 assertions:
class TestValueChain:
def test_paths_exist(self, graph):
paths = value_chain(graph)
assert len(paths) >= 2, (
"Expected at least 2 paths (unlevered and levered). "
"Wrong answer: no path means a flows-to edge was never connected."
)
def test_unlevered_path_length(self, graph):
paths = value_chain(graph)
lengths = [len(p) - 1 for p in paths]
assert 5 in lengths, (
"Unlevered path should have 5 edges. "
"Wrong answer: missing step in FCF -> DR -> EV -> ND -> EqV -> PSV."
)
def test_unlevered_path_contains_net_debt(self, graph):
paths = value_chain(graph)
long_paths = [p for p in paths if len(p) - 1 == 5]
for p in long_paths:
assert "net_debt" in p, (
"Unlevered path must pass through net_debt. "
"Wrong answer: reported enterprise value as equity value."
)
def test_levered_path_length(self, graph):
paths = value_chain(graph)
lengths = [len(p) - 1 for p in paths]
assert 3 in lengths, (
"Levered path should have 3 edges. "
"Wrong answer: levered route not connected."
)
What does each test prove?
- test_paths_exist: at least 2 routes exist from cash flow to share price. A failure means a flows-to edge was never typed.
- test_unlevered_path_length: the full DCF chain has exactly 5 edges (FCF to discount rate to EV to net debt to equity to per-share value).
- test_unlevered_path_contains_net_debt: the unlevered path passes through net_debt. Without this, you conflated enterprise value with equity value.
- test_levered_path_length: the direct-to-equity route has 3 edges, skipping EV and net debt.
- Save tests/test_queries.py and run the value chain tests:
pytest tests/test_queries.py::TestValueChain -v
You should see 4 tests passing. If any fail, the failure message names exactly which edge or node is missing from your graph.
- Next, add the EXCLUDED_ACTIVITIES constant below the TestValueChain class:
EXCLUDED_ACTIVITIES = [
"investment_advisory",
"broker_dealer_execution",
"fund_raising",
"backtest_marketing",
]
- Immediately below the constant, add the TestStudyScope class:
class TestStudyScope:
def test_excluded_activities_exist(self, graph):
"""Step 1: assert all 4 excluded activities exist with requires-registration."""
registered = registered_activities(graph)
for activity in EXCLUDED_ACTIVITIES:
assert activity in registered, (
f"'{activity}' must exist and carry a requires-registration edge."
)
def test_excluded_not_in_study(self, graph):
"""Step 2: assert study query returns none of the 4."""
permitted = study_scope(graph)
for activity in EXCLUDED_ACTIVITIES:
assert activity not in permitted, (
f"'{activity}' returned by study_scope. "
"Wrong answer: study scope was typed for work the law sends to registration."
)
def test_study_scope_not_empty(self, graph):
"""Guard against passing on an empty graph."""
permitted = study_scope(graph)
assert len(permitted) > 0, (
"Study scope returned nothing. "
"Wrong answer: no permitted-as-study edge was typed."
)
Why is this a two-step ordered assertion?
The first test proves the 4 excluded activities actually exist and carry the right edge. Only then does the second test assert the study query excludes them. An absence test on an empty graph proves nothing.
The third test guards the other direction: study scope must not be empty either. If no permitted-as-study edge was typed, the test passes vacuously.
- Save and run the study scope tests:
pytest tests/test_queries.py::TestStudyScope -v
You should see 3 tests passing. If test_excluded_activities_exist fails, the named activity is missing its requires-registration edge in graph.yaml.
- Add the TestBuildCoverage class below TestStudyScope:
class TestBuildCoverage:
def test_landmarks_covered(self, graph):
result = build_coverage(graph)
assert result["landmarks"] == [], (
f"Uncovered landmarks: {result['landmarks']}. "
"A landmark returned was collected, never practiced."
)
def test_domains_covered(self, graph):
result = build_coverage(graph)
assert result["domains"] == [], (
f"Uncovered domains: {result['domains']}. "
"A domain returned is an area the path skips."
)
def test_instruments_covered(self, graph):
result = build_coverage(graph)
assert result["instruments"] == [], (
f"Uncovered instruments: {result['instruments']}. "
"An instrument returned was named but never exercised."
)
What does this code do?
Each test asserts a zero count for its category. A non-zero count means you collected something (a landmark, a domain, an instrument) but never connected it to a build node. The failure message names the uncovered items so you know exactly what to fix in your graph.
- Save and run the coverage tests:
pytest tests/test_queries.py::TestBuildCoverage -v
You should see 3 tests passing. If any fail, the output lists which nodes need an edge to a build node.
- Add the CFA topic mapping constant below TestBuildCoverage:
CFA_LEVEL_I_TOPICS = [
"Ethical and Professional Standards",
"Quantitative Methods",
"Economics",
"Financial Statement Analysis",
"Corporate Issuers",
"Equity Investments",
"Fixed Income",
"Derivatives",
"Alternative Investments",
"Portfolio Management",
]
- Below the constant, add the TestCFACoverage class:
class TestCFACoverage:
def test_all_topics_mapped(self, graph):
"""Every CFA Level I topic area maps to at least one node."""
node_ids = set(graph.nodes())
unmapped = []
topic_map = {
"Ethical and Professional Standards": "ethics_and_professional_standards",
"Quantitative Methods": "quantitative_methods",
"Economics": "economics",
"Financial Statement Analysis": "financial_statement_analysis",
"Corporate Issuers": "corporate_finance",
"Equity Investments": "equity_investments",
"Fixed Income": "fixed_income",
"Derivatives": "derivatives",
"Alternative Investments": "alternative_investments",
"Portfolio Management": "portfolio_management",
}
for topic, node_id in topic_map.items():
if node_id not in node_ids:
unmapped.append(topic)
assert unmapped == [], (
f"CFA topics with no mapped node: {unmapped}. "
"Every topic area must map to a node in the graph."
)
What does this test prove?
Every CFA Level I topic area must map to at least one node in your graph. The topic_map dictionary maps official topic names to your graph's node IDs. If a node is missing, the failure names which CFA topic has no representation in your structure.
- Save tests/test_queries.py.
Run the full query pack and interpret the results
Before you run the full suite, think about what you expect. Do you think every test will pass on the first run? If a test fails, the failure message tells you which node or edge in your graph needs fixing.
- Run the entire query test suite:
pytest tests/test_queries.py -v
You should see all 11 tests passing green. Each test name states what a wrong answer would mean about your graph.
What does each failure tell you?
- A missing path means a flows-to edge was never connected between two quantities.
- A study query returning a registered activity means a permitted-as-study edge was wrongly added to work the law sends to registration.
- A non-zero coverage count means a node was collected but never linked to a build.
- A wrong count is evidence of your inventory, never a finding about markets.
- If any tests fail, fix the relevant edges or nodes in graph.yaml and rerun until all 11 pass.
- Once green, confirm the integrity tests still pass:
pytest tests/ -v
You should see all 14 tests passing (3 integrity + 11 query). Your graph now has a scored, self-checking structure.
Tests were green, now integrity fails?
If you edited graph.yaml to fix query failures, you may have created an orphan node or broken reachability. Check the integrity failure message for the offending node, then add back the missing edge.
Why did my integrity tests break after I edited the graph?
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/queries.py and tests/test_queries.py.
ⓧ I'd like to double check the full code
Scroll up and confirm each chunk is present in order. Your src/queries.py should contain these functions in order:
- value_chain (filters to flows-to edges, finds all simple paths).
- study_scope (finds permitted-as-study activity nodes).
- registered_activities (finds requires-registration activity nodes).
- build_coverage (finds uncovered landmarks, domains, instruments).
Your tests/test_queries.py should contain these sections in order:
- Imports and session-scoped graph fixture.
- TestValueChain class with 4 tests.
- EXCLUDED_ACTIVITIES constant (4 items).
- TestStudyScope class with 3 tests.
- TestBuildCoverage class with 3 tests.
- CFA_LEVEL_I_TOPICS constant (10 items).
- TestCFACoverage class with 1 test.
You can now answer structural questions about finance by running a query, not reciting from memory. Next up, you will export the graph as a browsable diagram and a retrieval-practice card deck.
Export the Map and Ship the Documentation
Your query pack now scores your understanding cold. But a graph that only lives in pytest output is invisible. Nobody can browse it, retrieve from it, or teach from it.
In this step you write two exporters that turn the same source file into a browsable diagram and a retrieval-practice deck. Then you write the documentation pages that complete the teach-back and ship a tagged release.
In this step, get ready to:
- Export the graph as a Mermaid flowchart with nodes grouped by domain and edges labeled with type.
- Generate a 20-card Markdown deck and run it cold with map closed.
- Write documentation pages with regulatory framing and ship a tagged release.
Implement the Mermaid exporter
The Mermaid exporter walks the graph and emits plain text that any Mermaid-compatible viewer renders as a flowchart. Nodes are grouped inside subgraph blocks by domain, landmark nodes carry a label, and every edge shows its type.
- Open src/export.py and add the module docstring and imports:
"""Export the graph as Mermaid diagram and Markdown card deck."""
from pathlib import Path
import networkx as nx
- Below the imports, add the export_mermaid function:
def export_mermaid(G: nx.DiGraph, path: str | Path) -> None:
"""Write a Mermaid flowchart grouping nodes by domain."""
path = Path(path)
lines = ["graph LR"]
# Collect domain nodes and track which nodes get assigned to a subgraph
domains = [n for n, d in G.nodes(data=True) if d.get("type") == "domain"]
assigned = set()
for domain in sorted(domains):
lines.append(f" subgraph {domain}")
belonging = set()
for u, v, d in G.edges(data=True):
if d.get("type") == "belongs-to" and v == domain:
belonging.add(u)
for member in sorted(belonging):
landmark = G.nodes[member].get("landmark", "")
label = f"{member}"
if landmark:
label = f"{member} [{landmark}]"
lines.append(f" {member}[{label}]")
assigned.add(member)
lines.append(" end")
# Add remaining nodes not assigned to any domain
for node in sorted(G.nodes()):
if node not in assigned and node not in domains:
landmark = G.nodes[node].get("landmark", "")
label = f"{node}"
if landmark:
label = f"{node} [{landmark}]"
lines.append(f" {node}[{label}]")
# Add all edges with their type labels
for u, v, data in G.edges(data=True):
edge_type = data.get("type", "")
lines.append(f" {u} -->|{edge_type}| {v}")
path.write_text("\n".join(lines), encoding="utf-8")
What does this code do?
The function builds a Mermaid flowchart string line by line. It groups nodes into subgraph blocks by following belongs-to edges back to their domain. Any node without a domain gets listed after the subgraphs.
Every edge is rendered as an arrow with its type label (-->|flows-to|). Landmark nodes get their landmark string appended in square brackets so they stand out visually.
- Save src/export.py.
- Verify the function is importable by running:
python -c "from src.export import export_mermaid; print('mermaid exporter ready')"
You should see mermaid exporter ready printed with no import errors.
Seeing an ImportError?
Make sure your virtual environment is active and you are running the command from the finance-map/ project root. The src/__init__.py file must exist for Python to treat src as a package.
help me debug an ImportError when importing from src.export
Implement the card deck exporter
The card deck turns your graph into 20 retrieval-practice prompts. Each card has a question as the prompt and a graph-derived answer as the response. The deck covers five categories: value chain, study scope, routes, coverage, and methods.
- In src/export.py, below the export_mermaid function, begin the export_cards function with the value chain cards:
def export_cards(G: nx.DiGraph, path: str | Path) -> None:
"""Write a Markdown card deck with 20 query-based prompts and answers."""
path = Path(path)
cards = []
cards.append("# Finance Map Card Deck\n")
cards.append("## Value Chain Cards\n")
cards.append("**Q:** How does a forecast cash flow become a per-share value on the unlevered route?\n")
cards.append("**A:** free_cash_flow -> discount_rate -> enterprise_value -> net_debt -> equity_value -> per_share_value (5 flows-to edges). The discount rate turns future cash into present value. The net-debt bridge subtracts debt claims to isolate equity.\n")
cards.append("---\n")
cards.append("**Q:** What is the levered route?\n")
cards.append("**A:** free_cash_flow -> discount_rate -> equity_value -> per_share_value (3 flows-to edges). Discounts cash flow to equity directly, skipping enterprise value and net debt.\n")
cards.append("---\n")
cards.append("**Q:** What does a discount rate consist of?\n")
cards.append("**A:** A risk-free rate plus a premium for risk. It turns a future cash flow into money today.\n")
cards.append("---\n")
cards.append("**Q:** What is enterprise value?\n")
cards.append("**A:** The whole business priced for everyone who funded it (debt + equity holders).\n")
cards.append("---\n")
cards.append("**Q:** What is the net-debt bridge?\n")
cards.append("**A:** The step from enterprise value to equity value. Equity value = enterprise value minus net debt.\n")
cards.append("---\n")
What does the value chain section cover?
These 5 cards test whether you can trace the full DCF path from cash flow to share price. The unlevered route (5 edges) passes through enterprise value and net debt. The levered route (3 edges) skips both.
Each answer references the exact node names from your graph. A wrong answer during retrieval practice traces to a missing flows-to edge.
- Continue the function by adding the study scope cards section:
cards.append("## Study Scope Cards\n")
cards.append("**Q:** Which activities can you practice as self-directed study without registration?\n")
from src.queries import study_scope as _study_scope
permitted = _study_scope(G)
cards.append(f"**A:** {', '.join(permitted) if permitted else 'None found (check graph)'}.\n")
cards.append("---\n")
cards.append("**Q:** Name the 4 activities that require registration.\n")
cards.append("**A:** investment_advisory, broker_dealer_execution, fund_raising, backtest_marketing. Each carries a requires-registration edge to its regulatory item.\n")
cards.append("---\n")
cards.append("**Q:** Why is a CFA charter not permission to manage money?\n")
cards.append("**A:** A charter is a credential (private certification). Authority to manage or advise on other people's money comes from a registration (Advisers Act or broker-dealer/FINRA).\n")
cards.append("---\n")
cards.append("**Q:** Why is a backtest not a track record?\n")
cards.append("**A:** A backtest is a strategy replayed over past data. It suffers from look-ahead bias, survivorship bias, overfitting, and ignores transaction costs. It is not a prediction. The graph types it as an activity requiring registration to show to prospective investors.\n")
cards.append("---\n")
cards.append("**Q:** What does Regulation D govern?\n")
cards.append("**A:** Private fund raising (a securities offering). It requires a Form D filing and implicates the Investment Company Act. It needs securities counsel.\n")
cards.append("---\n")
Why does this section import study_scope at runtime?
The study scope card dynamically queries the graph so the answer always reflects the current structure. If you add or remove a permitted-as-study edge, the card's answer updates automatically the next time you export.
- Add the route cards section:
cards.append("## Route Cards\n")
cards.append("**Q:** Name the four routes into finance practice.\n")
cards.append("**A:** Academic doctorate (asset-pricing research), charter route (CFA + MBA + registration), quant route (MFE + algo/ML), search fund (buy and run a company).\n")
cards.append("---\n")
cards.append("**Q:** Which route is built for a coder?\n")
cards.append("**A:** The quant route: master's in financial engineering plus algorithmic and machine-learning work.\n")
cards.append("---\n")
cards.append("**Q:** What does the academic doctorate route train you for?\n")
cards.append("**A:** To study markets (asset-pricing and econometrics research), not to work in them.\n")
cards.append("---\n")
- Add the coverage cards section:
cards.append("## Coverage Cards\n")
cards.append("**Q:** What does a non-zero build-coverage count mean?\n")
cards.append("**A:** A landmark returned was collected but never practiced. A domain returned is an area the path skips. An instrument returned was named but never exercised in a build. The count is a regression check on your inventory, not a finding about markets.\n")
cards.append("---\n")
cards.append("**Q:** What free data sources feed the later builds?\n")
cards.append("**A:** FRED (St. Louis Fed), SEC EDGAR, Kenneth French Data Library (Dartmouth), Damodaran Online (NYU Stern).\n")
cards.append("---\n")
- Add the method cards section and the file write to finish the function:
cards.append("## Method Cards\n")
cards.append("**Q:** Why must every edge come from the closed vocabulary?\n")
cards.append("**A:** An untyped edge is a feeling, not a claim anyone can query. The closed vocabulary makes the graph machine-checkable.\n")
cards.append("---\n")
cards.append("**Q:** Why write the expected answer before running the query?\n")
cards.append("**A:** A query you interpret afterwards can never fail. Fixtures-first testing means the test fails on wrong structure, not wrong assertion.\n")
cards.append("---\n")
cards.append("**Q:** When a query returns a count, what must you say about it?\n")
cards.append("**A:** What it is evidence of. A count over nodes you added is a regression check on your inventory, not a finding about markets.\n")
cards.append("---\n")
cards.append("**Q:** What is a concept map NOT?\n")
cards.append("**A:** A syllabus to read. It is a structure to interrogate. The deliverable is the query answered cold, never the picture alone.\n")
cards.append("---\n")
cards.append("**Q:** What does the honest route build, and what does it not build?\n")
cards.append("**A:** It builds knowledge, quantitative craft, and a portfolio of models. It does not build a degree, a charter, a registration, capital, or a track record of real returns.\n")
path.write_text("\n".join(cards), encoding="utf-8")
What do the method cards test?
These cards test your understanding of WHY the graph is built this way. A closed vocabulary makes edges queryable. Fixtures-first means the test can actually fail. A count is evidence of your inventory, never a finding about markets.
When you run the deck cold with map closed, a wrong answer on a method card means you memorised the words but missed the principle.
- Save src/export.py.
- Verify both functions are importable by running:
python -c "from src.export import export_mermaid, export_cards; print('both exporters ready')"
You should see both exporters ready printed.
Seeing a SyntaxError?
Check that all cards.append( lines have matching parentheses and that the f-string in the study scope section uses the correct quote nesting. The from src.queries import study_scope as _study_scope line must be indented inside the function body.
help me fix a SyntaxError in my export_cards function
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/export.py with both functions.
ⓧ I'd like to double check the full code
Your src/export.py should contain these sections in order:
- Module docstring and imports (Path and networkx).
- The export_mermaid function building a Mermaid flowchart with domain subgraphs, unassigned nodes, and labeled edges.
- The export_cards function with 5 value chain cards, 5 study scope cards, 3 route cards, 2 coverage cards, and 5 method cards, ending with path.write_text.
Scroll up through the chunks above and confirm each section is present in your file. The total is 20 cards across all categories.
Run the exporters and verify the outputs
Both exporters read from the same graph and produce plain-text files. Running them proves the graph renders as a browsable structure and as a testable deck.
- Run both exporters by executing this script:
python -c "
from src.builder import load_graph
from src.export import export_mermaid, export_cards
G = load_graph('graph.yaml')
export_mermaid(G, 'exports/topology.mmd')
export_cards(G, 'exports/cards.md')
print('exported', len(G.nodes()), 'nodes to topology.mmd and cards.md')
"
- Confirm both files were created by running:
ls exports/
You should see topology.mmd and cards.md listed.
Seeing a FileNotFoundError?
The exports/ directory must already exist. If you skipped creating it in Step 1, create it now with mkdir exports and rerun the export script.
help me fix a FileNotFoundError when running the exporters
Before you open the Mermaid file, what do you expect to see? Think about how 62 nodes grouped by 11 domains will look as a flowchart.
- Open exports/topology.mmd in a Mermaid-compatible viewer. You can paste the content into mermaid.live or use a VS Code Mermaid preview extension.
- Visually confirm the value chain is traceable: find free_cash_flow and follow the flows-to arrows to per_share_value.
- Confirm all 11 domains appear as subgraph groups.
What should I see in the Mermaid diagram?
A directed flowchart with 11 labeled subgraph boxes (one per domain). Inside each box you see the nodes that belong to that domain. Arrows between nodes carry labels like flows-to, requires-registration, and belongs-to.
Landmark nodes display their landmark text in square brackets. The diagram is dense but traceable. If any node is isolated with no arrows, your graph has a structural error.
- Now run the card deck cold with map closed. Open exports/cards.md and test yourself: read each question, attempt the answer from memory, then check against the printed answer. Record your score out of 20.
What does 'map closed' mean?
Close the Mermaid diagram, close graph.yaml, and close any notes. Answer each card from memory only. The pass bar is 17/20. Every miss traces to the node or edge you got wrong. No score is claimed before one is measured.
Write the documentation pages and tag the release
The documentation pages complete the teach-back. Each page has a specific purpose, and every page carries the regulatory framing. Use your AI agent to help draft these, but own the content. The three beginner misreadings you correct must come from what YOU got wrong building this graph.
- Create docs/01-learning.md covering what you were learning and why. Include the three habits: type every edge from the closed vocabulary, keep study apart from registration, write expected answers first.
- Create docs/02-build.md documenting the build and how to run it. Include a worked example of the value-chain query from free_cash_flow to per_share_value, showing both routes.
- Create docs/03-self-check.md with the evidence set: each query's expected value, what a wrong answer means, your actual pytest output, and your card-deck score recorded as a bar (17/20) not as an outcome.
- Create docs/04-teach-back.md as your presentation with lessons learned. Explain the discount-rate-to-share-price chain and the regulatory boundary in your own words. Correct the three beginner misreadings:
- Credential is not permission. A charter is a private certification. Authority comes from registration.
- Backtest is not track record. A strategy replayed over past data is not a prediction.
- Concept map is not syllabus. It is a structure to interrogate. The deliverable is the query answered cold.
- Create docs/05-source-map.md recording which reference anchored each node. Use these anchors:
- CFA Level I 2026 topic-area outline (cfainstitute.org, outline level, not the paid readings).
- Brealey/Myers/Allen/Edmans Principles of Corporate Finance 2025 release (McGraw-Hill).
- Bodie/Kane/Marcus Investments 13th edition (McGraw-Hill, 2024).
- Hull Options, Futures, and Other Derivatives 11th edition (Pearson, 2022).
What goes in the regulatory framing on every page?
Every documentation page must carry this framing: every build is a study or paper mock-up. Not advice, not a recommendation, not a solicitation, not a track record. Authority comes from a registration, never a credential.
A backtest is not a prediction. This path builds knowledge, quantitative craft, and a portfolio of models. It does not build a degree, a charter, a registration, capital, or a track record of real returns. Nothing here is investment, financial, tax, or legal advice. Do not trade real money, manage anyone else's, or raise capital on the strength of a build.
- Confirm all 5 docs pages exist by running:
ls docs/
You should see 01-learning.md, 02-build.md, 03-self-check.md, 04-teach-back.md, and 05-source-map.md listed.
- Stage all changes, commit, and push by running:
git add .
git commit -m "Add exporters and documentation pages"
git push
- Tag the release by running:
git tag v1.0.0
git push origin v1.0.0
- Close the remaining Linear tickets for the export and documentation deliverables.
Do you expect the full test suite to still pass after adding the exporters? Predict the result before running.
- Run the full test suite one final time to confirm nothing broke:
pytest -v
All integrity and query tests should pass green. The exporters do not change the graph structure, so your existing tests remain valid.
Tests failing after adding export.py?
The export module should not affect existing tests. If you see failures, check that you did not accidentally modify graph.yaml while writing the documentation. Rerun pytest tests/test_integrity.py -v to isolate whether it is an integrity or query issue.
help me figure out why my tests broke after adding the export module
Your graph is now a shipped artifact: a browsable diagram, a scored card deck, and documentation pages that prove you built the structure and tested it cold. The graph is a launchpad. Later builds attach to it and thicken the branches.
Secret mission
Wire the Agent Orchestrator
Your pipeline stages are already isolated functions. Wrap them as callable agents with a shared context to turn the system into an extensible framework. Later you could swap in an LLM-powered node extractor without changing the pipeline shape.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Local finance-map/ project directory (source files, tests, exports, docs, virtual environment).
- Private GitHub repository.
- Linear project with tickets.
Keep everything running
No action needed. Choose this if you're still actively building or plan to attach later builds (DCF model, options pricer, factor model) to the graph.
- Your finance-map/ directory stays on your Desktop with all source files, tests, and exports intact.
- Your GitHub repo remains private and accessible for future pushes.
- Your Linear project stays open for tracking future deliverables.
Pause - I'll come back to this later
Deactivate the virtual environment to free up terminal state. Your files and remote resources stay intact for when you return.
- In your terminal, deactivate the virtual environment by running:
deactivate
- Your project files remain on disk. When you return, reactivate with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).
- Your GitHub repo and Linear project remain unchanged.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your local files, the GitHub repo, and the Linear project.
- Delete the local project directory by running:
cd ~/Desktop
rm -rf finance-map
On Windows, run:
cd ~/Desktop
Remove-Item -Recurse -Force finance-map
- Delete the GitHub repository. Open your repo page on GitHub, click the Settings tab, scroll down to the Danger Zone section, and click Delete this repository. Confirm the deletion when prompted.
- Archive or delete the Linear project. Open your Linear project, click the ... menu at the top of the project view, and select Delete project. Confirm when prompted.
Nice Work!
Nice Work!
You just built a scored, self-checking map of the entire field of finance. The graph encodes 62 typed nodes and their relationships in a single YAML source file, loaded into a NetworkX DiGraph, verified by an integrity checker, queried by a cold test suite, and exported as a browsable diagram and a retrieval-practice deck.
You've learned how to:
- Encode a knowledge domain as a typed knowledge graph with a closed vocabulary of 10 node types and 8 edge types, validated on load and enforced by integrity checks that catch orphans, illegal edges, and disconnected components.
- Write a fixtures-first query pack that scores your understanding cold. The value-chain query traces how a forecast cash flow becomes a share price. The study-scope query proves which activities you can practice without registration. The build-coverage query proves nothing was collected without being practiced.
- Export a Mermaid topology diagram and a 20-card Markdown deck from the same source, with a pass bar of 17/20 map closed. The graph is a launchpad: later builds (DCF model from FRED, options pricer, factor model from Kenneth French, filings from EDGAR) attach to it and thicken the branches.
- Secret Mission: You wired an agent orchestrator pattern making the pipeline extensible to LLM-powered stages, with a shared context dataclass and per-stage timing.
Ready to quiz yourself?