Recommended resources
The freely available resources this lesson will direct you to.
Hello. In the previous lesson, you wrote tests for deterministic market-data and indicator logic. Those tests protect a rule once it exists. This lesson addresses a related failure mode: values such as a 20-day window, 100-day window, or maximum number of positions buried inside the rule itself.
We are closing the Python Quant Workflow and System Blueprint module by making strategy settings explicit, versionable, and safe to load. The objective is not to decide whether a particular moving-average or momentum strategy is good. It is to ensure that, once you decide which parameters to research, the application accepts only a complete and valid specification.
Study time: about 40 minutes.
Hard-coded values obscure the strategy you actually tested
Consider a research function written like this:
def add_features(close):
momentum = close.pct_change(63)
fast_average = close.rolling(20).mean()
slow_average = close.rolling(100).mean()
return momentum, fast_average, slow_average
The code runs, but it hides important questions:
- Why are the windows 63, 20, and 100?
- Which exact values produced a particular backtest result?
- Can you run a parameter variation without editing source code?
- What prevents someone from using a nonsensical or misspelled value?
- Can you recreate the same result after the code evolves?
These numbers are not implementation details. They are part of the strategy specification. A backtest using a 20-day and 100-day pair is a materially different experiment from one using 10 and 50.
A configuration file moves such values outside the code while giving them names. Your Python modules retain the mechanism for computing features; a strategy configuration supplies the chosen inputs.

For research parameters, TOML is a strong default:
- It is readable enough to review in a pull request or commit.
- It preserves basic types such as integers, floats, strings, Booleans, lists, dates, and tables.
- Python 3.11+ includes
tomllibin the standard library for reading it. - A committed TOML file can be stored alongside a backtest result and tied to a Git revision.
Python and TOML: Read, Write, and Configure with tomllib
Read Real Python’s introduction to TOML configuration. It establishes the key distinction for this course: source code implements behavior, while configuration makes chosen settings visible and changeable.
In “Use TOML as a Configuration Format,” read the subsection “Configurations and Configuration Files,” especially why externalize parameters. Continue into “TOML Schema Validation”; from the example of a required server URL through schema validation, focus on the difference between syntactically valid TOML and a configuration that meets application rules. Finally, in “Load TOML With Python,” subsection “Read TOML Documents With tomli and tomllib,” read loading TOML to see why TOML becomes nested Python dictionaries after parsing.
A useful initial project layout is:
algo-trader/
├── configs/
│ └── strategies/
│ └── nse_swing_momentum.toml
├── src/
│ └── algo_trader/
│ └── strategy_config.py
└── tests/
└── test_strategy_config.py
Keep a strategy file under version control. Do not treat it as a place for Kite API secrets, passwords, or access tokens. Those belong in environment variables or a secrets manager, not in a research configuration committed to Git.
A TOML file declares the experiment
Here is a deliberately small strategy configuration:
# configs/strategies/nse_swing_momentum.toml
[strategy]
name = "nse_swing_momentum_v1"
momentum_lookback = 63
fast_ma_window = 20
slow_ma_window = 100
max_positions = 10
rebalance_frequency = "weekly"
The [strategy] line begins a TOML table. When parsed, the result has a top-level "strategy" key whose value is a dictionary containing the named parameters.
This file already improves on hard-coded literals because it is reviewable and can be copied with each experiment. But TOML alone cannot know your research rules. All of the following are valid TOML syntax:
fast_ma_window = "twenty"
slow_ma_window = 10
max_postions = 10
They are nevertheless bad configuration:
"twenty"is a string where computation requires an integer.- A fast moving-average window should be shorter than its corresponding slow window.
max_postionsis misspelled. Silently ignoring it could cause an unintended default to be used.
That is the role of a schema: it converts untrusted parsed data into an object your strategy code can rely on.
Make the schema executable with Pydantic
Pydantic uses Python type hints and constraints to validate data at runtime. We will use Pydantic v2 and its BaseModel here.
Install it in the isolated environment created earlier in the course:
python -m pip install "pydantic>=2,<3"
Create src/algo_trader/strategy_config.py:
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
TradingWindow = Annotated[int, Field(ge=2, le=252)]
PositionCount = Annotated[int, Field(ge=1, le=25)]
class StrategyConfig(BaseModel):
model_config = ConfigDict(
extra="forbid",
frozen=True,
strict=True,
)
name: str = Field(min_length=1)
momentum_lookback: TradingWindow
fast_ma_window: TradingWindow
slow_ma_window: TradingWindow
max_positions: PositionCount
rebalance_frequency: Literal["weekly", "monthly"]
@model_validator(mode="after")
def fast_window_must_be_shorter(self):
if self.fast_ma_window >= self.slow_ma_window:
raise ValueError(
"fast_ma_window must be less than slow_ma_window"
)
return self
class StrategyFile(BaseModel):
model_config = ConfigDict(
extra="forbid",
frozen=True,
strict=True,
)
strategy: StrategyConfig
The constraints are a project policy, not universal finance laws. In this example, the maximum window of 252 represents roughly one Indian trading year; the maximum of 25 positions is an early guardrail for a small swing-trading system. As your strategy and capital assumptions develop, you may revise these limits deliberately, with a commit and a corresponding test.
The model makes several promises:
| Schema feature | What it protects |
|---|---|
int with ge and le constraints |
Windows and position counts stay within an intentional range. |
Literal["weekly", "monthly"] |
A typo such as "weekley" fails at startup. |
extra="forbid" |
Unknown fields, including misspellings, fail rather than being ignored. |
strict=True |
A quoted TOML value such as "63" is not quietly accepted as an integer. |
frozen=True |
Code cannot casually mutate a loaded configuration halfway through a run. |
@model_validator |
Relationships involving more than one field are checked together. |
The cross-field rule deserves attention. Individual field checks cannot detect that fast_ma_window = 100 and slow_ma_window = 20 are reversed; each is individually a valid integer. The model validator checks the relationship after Pydantic has validated the fields.
Python Pydantic Tutorial: Complete Data Validation Course (Used by FastAPI)
Watch Corey Schafer’s “Python Pydantic Tutorial: Complete Data Validation Course” for the two Pydantic techniques used in the schema: declarative numeric constraints and whole-model validation.
Watch field constraints to see how Annotated and Field express numeric bounds. Then skip to model validation, focusing on why a model validator is appropriate when a rule compares multiple fields.
A schema should be strict enough to catch mistakes, but not pretend to validate facts it cannot know. For example, it can ensure max_positions is a positive integer. It cannot, at this stage, guarantee that your selected universe contains enough liquid NSE stocks. That needs validated market data and point-in-time universe rules, which you will develop later.
Load once at the boundary, then pass validated settings explicitly
A clean boundary has two stages:
tomllibparses the TOML file into ordinary Python dictionaries.- Pydantic validates that dictionary and returns an immutable
StrategyFile.
Add this loader below the models in strategy_config.py:
from pathlib import Path
import tomllib
def load_strategy_file(path: Path):
with path.open("rb") as file:
raw_config = tomllib.load(file)
return StrategyFile.model_validate(raw_config)
The file must be opened in binary mode for tomllib.load().
At an entry point, load the configuration deliberately:
from pathlib import Path
from algo_trader.strategy_config import load_strategy_file
config_path = Path("configs/strategies/nse_swing_momentum.toml")
strategy_file = load_strategy_file(config_path)
strategy = strategy_file.strategy
print(strategy.name)
print(strategy.momentum_lookback)
Do this near the boundary of a research run, command-line program, or scheduled job. Avoid loading a configuration as an import-time side effect. Explicit loading makes it clear which file was used and makes tests easier to control.
Your feature functions can now accept the validated object instead of embedding numerical choices:
import pandas as pd
from algo_trader.strategy_config import StrategyConfig
def add_strategy_features(
close: pd.Series,
strategy: StrategyConfig,
) -> pd.DataFrame:
return pd.DataFrame(
{
"momentum": close.pct_change(
periods=strategy.momentum_lookback
),
"fast_ma": close.rolling(
window=strategy.fast_ma_window,
min_periods=strategy.fast_ma_window,
).mean(),
"slow_ma": close.rolling(
window=strategy.slow_ma_window,
min_periods=strategy.slow_ma_window,
).mean(),
}
)
The function receives an already-valid StrategyConfig. It does not need to repeat checks such as “is the window positive?” for every call. Validation happens at the application boundary, while the feature function concentrates on its calculation.
Two kinds of failure now stay clearly separated:
| Failure | Example | Where it appears |
|---|---|---|
| TOML syntax failure | Missing quote or malformed table | tomllib fails while parsing |
| Schema failure | Misspelled parameter, string instead of integer, reversed windows | Pydantic raises ValidationError |
| Strategy behavior issue | Strategy loses money or trades too rarely | Research and backtesting, not configuration loading |
When recording a backtest, keep the exact TOML file with the output, or serialize the validated model with strategy.model_dump(). Combined with the Git commit containing the code, that gives you an auditable statement of what was actually tested.
Test the configuration policy, not just the happy path
The unit-testing discipline from the previous lesson applies directly. You are not testing whether Pydantic itself can parse an integer. You are testing your configuration contract: unknown settings are forbidden, the fast window must remain shorter, and valid files load into the object your research code expects.
Create tests/test_strategy_config.py:
import pytest
from pydantic import ValidationError
from algo_trader.strategy_config import StrategyConfig
def valid_parameters():
return {
"name": "nse_swing_momentum_v1",
"momentum_lookback": 63,
"fast_ma_window": 20,
"slow_ma_window": 100,
"max_positions": 10,
"rebalance_frequency": "weekly",
}
def test_strategy_config_accepts_complete_valid_parameters():
strategy = StrategyConfig.model_validate(valid_parameters())
assert strategy.fast_ma_window == 20
assert strategy.rebalance_frequency == "weekly"
def test_strategy_config_rejects_reversed_moving_average_windows():
parameters = valid_parameters()
parameters["fast_ma_window"] = 100
parameters["slow_ma_window"] = 20
with pytest.raises(
ValidationError,
match="fast_ma_window must be less than slow_ma_window",
):
StrategyConfig.model_validate(parameters)
def test_strategy_config_rejects_unknown_parameter():
parameters = valid_parameters()
parameters["max_postions"] = 10
with pytest.raises(ValidationError, match="extra"):
StrategyConfig.model_validate(parameters)
The last test is especially valuable. Without extra="forbid", a misspelled configuration key might be ignored. The program could run with a different parameter than the researcher intended, while appearing entirely successful.
You can also test file loading with pytest’s tmp_path fixture:
from algo_trader.strategy_config import load_strategy_file
def test_load_strategy_file_reads_valid_toml(tmp_path):
config_path = tmp_path / "strategy.toml"
config_path.write_text(
"""
[strategy]
name = "test_strategy"
momentum_lookback = 21
fast_ma_window = 10
slow_ma_window = 50
max_positions = 5
rebalance_frequency = "monthly"
""".strip(),
encoding="utf-8",
)
strategy_file = load_strategy_file(config_path)
assert strategy_file.strategy.name == "test_strategy"
assert strategy_file.strategy.max_positions == 5
Run these along with the tests from the prior lesson:
python -m pytest
Keep research parameters separate from environment settings and secrets
A strategy file and an environment file solve different problems.
| Category | Examples | Appropriate location |
|---|---|---|
| Versioned research parameters | Feature windows, rebalance frequency, number of positions | Committed TOML under configs/strategies/ |
| Environment-specific operations | Log level, data directory, paper versus live mode | Environment variables or a local .env file |
| Secrets | Kite API secret, access token, database password | Environment variables or a managed secrets store, never Git |
Pydantic also offers pydantic-settings, which loads a BaseSettings model from environment variables and .env files. This is useful for operational configuration and secrets, especially once you begin integrating external services.
However, do not casually use environment variables to override backtest parameters. An unnoticed environment value can make two ostensibly identical runs use different settings. If you later permit overrides for a controlled experiment, record the resolved configuration with the results.
Settings Management | Pydantic Docs
Read the official Pydantic Settings documentation to understand why environment-based settings are useful for operational values, but need explicit source tracking when reproducibility matters.
In “Dotenv (.env) support,” read dotenv precedence. Then read the short “Field value priority” section, especially source hierarchy. Notice that higher-priority sources can change a value without changing the checked-in file; that is appropriate for secrets and deployment settings, but must be carefully recorded for research parameters.
For now, apply a simple rule:
A backtest strategy’s parameters come from one explicit, committed TOML file. Secrets and machine-specific operational values do not.
Key takeaways
Hard-coded strategy values make research hard to inspect, compare, reproduce, and safely modify. A TOML configuration file gives those values visible names, while a Pydantic schema turns your assumptions into executable checks.
- Use version-controlled TOML files for strategy parameters.
- Parse TOML with
tomllibon Python 3.11+. - Validate parsed data with a Pydantic
BaseModel. - Reject unknown keys with
extra="forbid"so typos cannot silently change a run. - Use field constraints for individual parameters and model validators for relationships between parameters.
- Load configuration explicitly at the edge of a run and pass the validated object into research functions.
- Test the configuration policy, including invalid combinations and misspelled fields.
- Keep strategy parameters separate from environment-specific settings and secrets.
Next, the course moves from the local Python workflow to Indian Equity Markets and Zerodha Kite. You will begin by distinguishing NSE and BSE sessions, segments, holidays, and auction sessions that matter for equity swing trading.