The strategy tools family contains a single tool — propose_strategy — that accepts a declarative StrategySpec and stores it as an immutable, versioned row. Strategies in Ithaca are specs, not code. The agent never writes Python, never executes arbitrary logic, and never touches the backtest engine directly.
propose_strategy
Validate a StrategySpec dict against the frozen contract, stamp it with the caller’s tenant_id, and store it as an immutable versioned row. Returns the stored spec with its assigned strategy_id and version.
Arguments
| Argument | Type | Required | Description |
|---|
spec | object | Yes | A StrategySpec dict conforming to the frozen contract. See below. |
What it does
- Validates the
spec against the frozen StrategySpec JSON Schema. Unknown fields, malformed values, or missing required keys are rejected with a detailed error.
- Stamps the spec with the caller’s
tenant_id. This cannot be overridden — the tenant is taken from the authenticated session.
- Stores the spec as an immutable, versioned row. The
strategy_id is stable across versions; the version increments on each proposal. Prior versions are never mutated or deleted.
- Returns the stored spec with its
strategy_id, version, created_at, and a content hash.
The schema is closed. There is no escape hatch, no custom_code field, no eval hook. If a strategy can’t be expressed in the declarative spec, it can’t be proposed. This is the core safety guarantee — the backtest engine only ever runs trusted, audited numpy code against validated specs.
Return value
| Field | Type | Description |
|---|
strategy_id | string (UUID) | Stable identifier across versions. |
version | integer | Monotonically increasing version number. |
tenant_id | string (UUID) | The tenant that owns this strategy. |
spec | object | The stored spec (echoed back, normalized). |
content_hash | string | SHA-256 hash of the canonicalized spec. |
created_at | string (ISO 8601) | When the spec was stored. |
The StrategySpec contract
The StrategySpec is a declarative dict that describes what a strategy does, not how it does it. The backtest engine interprets the spec using trusted, audited code. The agent’s job is to fill in the spec; the engine’s job is to execute it.
Top-level fields
| Field | Type | Required | Description |
|---|
name | string | Yes | Human-readable strategy name. |
universe | object | Yes | The set of instruments to trade. |
signal | object | Yes | The signal generator (entry/exit rules). |
weights | object | Yes | The position sizing rule. |
risk | object | Yes | Risk constraints (max weight, stop loss, etc.). |
execution | object | No | Execution assumptions (slippage, fees). Defaults applied if omitted. |
horizon | string | No | Rebalance horizon: daily, weekly, monthly. Default daily. |
Full example: momentum strategy on NVDA
{
"name": "NVDA momentum (50/200 SMA cross)",
"universe": {
"type": "single",
"symbol": "NVDA"
},
"signal": {
"type": "sma_cross",
"fast": 50,
"slow": 200,
"direction": "long_only"
},
"weights": {
"type": "fixed_fraction",
"fraction": 1.0
},
"risk": {
"max_weight": 1.0,
"stop_loss_pct": null,
"max_positions": 1
},
"execution": {
"slippage_bps": 5,
"fee_bps": 1
},
"horizon": "daily"
}
How the engine interprets this
universe — the engine resolves the universe to a concrete set of symbols. single means one symbol; screen means run a screener; index means an index constituent list.
signal — the engine computes the signal series. sma_cross with fast=50, slow=200 means: go long when the 50-day SMA crosses above the 200-day SMA; exit when it crosses below. long_only prevents shorting.
weights — the engine sizes positions. fixed_fraction with fraction=1.0 means allocate 100% of capital to the signal.
risk — the engine applies risk constraints after sizing. max_weight caps any single position; stop_loss_pct exits on a drawdown threshold; max_positions limits breadth.
execution — the engine applies slippage and fees on every fill. 5bps slippage and 1bp fees are conservative defaults for liquid US equities.
horizon — the engine rebalances at this frequency. daily means the signal is evaluated every trading day.
Why a closed schema
The closed schema is the boundary between “agent creativity” and “trusted execution.” The agent can compose any combination of supported signal, weight, and risk primitives — but it cannot introduce new ones. New primitives are added by the Ithaca team after security review and backtest-engine integration.
This means:
- No arbitrary code execution. The agent never sends Python, JS, or any executable. The backtest engine only runs its own audited numpy code.
- Reproducible specs. A stored spec is a complete description of the strategy. Anyone with the
strategy_id and version can reproduce the exact same backtest.
- Auditable. Every spec is a small JSON document that a human can read and reason about. There is no hidden logic.
- Versioned. If the agent proposes a tweaked strategy, it gets a new
version on the same strategy_id. The old version is preserved for comparison.
Example call
{
"spec": {
"name": "NVDA momentum (50/200 SMA cross)",
"universe": { "type": "single", "symbol": "NVDA" },
"signal": { "type": "sma_cross", "fast": 50, "slow": 200, "direction": "long_only" },
"weights": { "type": "fixed_fraction", "fraction": 1.0 },
"risk": { "max_weight": 1.0, "stop_loss_pct": null, "max_positions": 1 },
"execution": { "slippage_bps": 5, "fee_bps": 1 },
"horizon": "daily"
}
}
Example response
{
"strategy_id": "strat_01HQKX2J3K4M5N6P7R8S9T0V1Y",
"version": 1,
"tenant_id": "tnt_01HQKX2J3K4M5N6P7R8S9T0V1X",
"spec": {
"name": "NVDA momentum (50/200 SMA cross)",
"universe": { "type": "single", "symbol": "NVDA" },
"signal": { "type": "sma_cross", "fast": 50, "slow": 200, "direction": "long_only" },
"weights": { "type": "fixed_fraction", "fraction": 1.0 },
"risk": { "max_weight": 1.0, "stop_loss_pct": null, "max_positions": 1 },
"execution": { "slippage_bps": 5, "fee_bps": 1 },
"horizon": "daily"
},
"content_hash": "sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
"created_at": "2025-01-15T14:33:12.001Z"
}
Validation errors
If the spec fails validation, propose_strategy returns a structured error with the JSON Schema path that failed:
{
"error": "spec_validation_failed",
"details": [
{
"path": "signal.fast",
"message": "must be less than signal.slow"
}
]
}
The agent should fix the spec and re-call propose_strategy. No partial state is stored on validation failure.
If you want to iterate on a strategy, propose multiple versions under the same logical name. The strategy_id will differ per proposal unless you explicitly resume — but grouping by name in the web UI makes comparison easy.