Updated 18 October 2026. A complete pandas backtest for a Jev-gated strategy, with cached decisions, next-open fills, trading costs and walk-forward tests.
To backtest a Jev trading strategy in Python, build each day's state from past bars only, turn the numbers into labels, ask Jev about each state through a cache, and trade at the next day's open with fees and slippage included. Then tune the confidence threshold with a walk-forward test and compare the result with buy and hold and with the same rules without Jev. If Jev does not beat the rule it replaces after costs, it has not earned its place.
This guide gives working code for every step, using pandas and TypeSafe's Python SDK, typesafe-sdk 0.7.5. Because labeled states repeat, a ten-year daily backtest needs only a few dozen Jev calls, well under a cent in API fees. It is an educational example, not financial advice: a backtest is a hypothesis, and the next step is paper trading. New to Jev? Start with what Jev AI can and cannot do for trading.
A Jev backtest at a glance
| Step | Rule | Common mistake |
|---|---|---|
| Data | Daily bars adjusted for splits and dividends | Unadjusted prices, or only today's index members |
| Features | Rolling windows over past bars | Bucket limits fitted on the whole period |
| State | Short labels, no ticker, no dates | Raw numbers or long price histories |
| Decisions | Cached, keyed on a pinned model version | Calling the API again on every run |
| Execution | Decide after the close, trade at the next open | Trading at the close you just used |
| Costs | Basis points per trade, tested at several levels | Free, perfect fills |
| Validation | Walk-forward tests plus a final holdout year | Tuning on the full history |
| Metrics | CAGR, max drawdown, Sharpe, hit rate, exposure, trades | Total return alone |
What you need
- Python 3.10 or later, which the TypeSafe SDK requires.
pip install pandas numpy typesafe-sdk, plusalpaca-pyif you take data from Alpaca.- A TypeSafe API key in the
TYPESAFE_API_KEYenvironment variable. - A pinned model version. This guide uses
jev-1.13.0; read what changed in Jev 1.14 before you switch.
Step 1: Load daily bars
Any source works if it gives daily open, high, low, close and volume, adjusted for splits and dividends. From a CSV file:
import numpy as np
import pandas as pd
def load_bars(path):
df = pd.read_csv(path, parse_dates=["date"], index_col="date").sort_index()
return df[["open", "high", "low", "close", "volume"]].dropna()
Or from Alpaca's market data API, whose free Basic plan includes stock history since 2016. Without a paid plan, consolidated (SIP) data must end at least 15 minutes ago, and paper-only accounts get IEX data. Leave out the feed setting and Alpaca picks the best feed your plan allows; use the same feed in the backtest and the live bot.
import os
from datetime import datetime
import pandas as pd
from alpaca.data.enums import Adjustment
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
data = StockHistoricalDataClient(os.environ["APCA_API_KEY_ID"], os.environ["APCA_API_SECRET_KEY"])
request = StockBarsRequest(symbol_or_symbols="SPY", timeframe=TimeFrame.Day, start=datetime(2016, 1, 1),
end=datetime(2026, 10, 16), adjustment=Adjustment.ALL)
bars = data.get_stock_bars(request).df.loc["SPY"]
bars.index = pd.to_datetime(bars.index.date)
bars = bars[["open", "high", "low", "close", "volume"]]
Alpaca stamps each daily bar with the start of the day in New York, but the bar holds the whole session. You only know the close after the market shuts, which is why every decision below waits for the next open.
Step 2: Compute features without look-ahead bias
Look-ahead bias means using information on day t that was not known until later. Rolling windows ending at row t are safe: each value uses that day and earlier days only. Fix bucket limits in advance or compute them from the past only; quantiles over the whole history leak the future into every row.
def add_features(df):
out = df.copy()
close = out["close"]
out["sma_50"] = close.rolling(50).mean()
out["sma_200"] = close.rolling(200).mean()
out["ret_20"] = close.pct_change(20)
out["vol_20"] = close.pct_change().rolling(20).std() * np.sqrt(252)
out["vol_ref"] = out["vol_20"].rolling(252).median()
out["dd_252"] = close / close.rolling(252).max() - 1
return out.dropna()
def to_state(row):
momentum = "rising" if row.ret_20 > 0.03 else ("falling" if row.ret_20 < -0.03 else "flat")
volatility = "high" if row.vol_20 > 1.25 * row.vol_ref else ("low" if row.vol_20 < 0.8 * row.vol_ref else "normal")
drawdown = "near the 1-year high" if row.dd_252 > -0.05 else ("moderate pullback" if row.dd_252 > -0.15 else "deep drawdown")
return {"long_term_trend": "up" if row.sma_50 > row.sma_200 else "down", "momentum_20d": momentum,
"volatility_vs_past_year": volatility, "drawdown": drawdown}
The state follows TypeSafe's guidance for Jev: do the math in code, send labels instead of raw numbers, and keep the state short. It also leaves out the ticker and the dates. A model should judge the pattern, not recognize the period.
Step 3: Cache Jev decisions so runs are repeatable and cheap
TypeSafe's docs do not promise identical probabilities for identical requests, so store each answer the first time and reuse it. The key covers the pinned model, the questions and the state, so changing any of them fetches a fresh answer.
import hashlib
import json
from pathlib import Path
from typesafe_sdk import Choice, Noul, TypeSafeClient
MODEL = "jev-1.13.0" # pin a version; never cache answers from an alias
SPEC = {
"position": {
"instructions": "Should a long-only daily trend strategy hold the index for the next session?",
"criteria": {"long": "Hold a long position", "flat": "Stay in cash"},
},
"stress": {"instructions": "Does the state describe a stressed, disorderly market?"},
}
CACHE = Path("jev_cache.json")
cache = json.loads(CACHE.read_text()) if CACHE.exists() else {}
client = TypeSafeClient(model=MODEL) # reads TYPESAFE_API_KEY
def ask_jev(state):
key = hashlib.sha256(json.dumps([MODEL, SPEC, state], sort_keys=True).encode()).hexdigest()
if key not in cache:
r = client.system_one(
state=state,
questions={"position": Choice(**SPEC["position"]), "stress": Noul(**SPEC["stress"])},
)
p = r.choices["position"]
cache[key] = {"model": r.model, "choice": p.choice, "confidence": p.confidence,
"p_long": p.probabilities["long"], "stress": r.nouls["stress"].noul,
"tokens": r.usage.input_tokens}
CACHE.write_text(json.dumps(cache, indent=1))
return cache[key]
bars = load_bars("spy_daily.csv") # or the Alpaca bars from Step 1
feats = add_features(bars)
states = pd.DataFrame([to_state(row) for row in feats.itertuples()], index=feats.index)
answers = pd.DataFrame([ask_jev(s) for s in states.to_dict("records")], index=feats.index)
px = bars.loc[feats.index]
print(len(cache), "distinct Jev calls for", len(answers), "trading days")
Four labels allow at most 54 distinct states. In a dry run on 12 years of made-up prices only 39 appeared, so the cache needed 39 calls: at roughly 300 input tokens each and $0.042 per 1M input tokens, a tiny fraction of a cent.
Step 4: Positions, next-open fills, fees and slippage
Your code makes the decision. The policy below goes long only when Jev picks "long", its confidence clears a threshold, and the stress answer stays below 0.5. With two options, Jev's confidence equals twice the top probability minus 1, so a threshold of 0.4 means the top option needs at least 70 percent.
COST = 0.0005 # 5 basis points per unit traded: an assumption, so test others
def positions(answers, threshold, max_stress=0.5):
go = (answers["choice"] == "long") & (answers["confidence"] >= threshold) & (answers["stress"] < max_stress)
return go.astype(float)
def backtest(px, pos, cost=COST):
open_ret = px["open"].shift(-1) / px["open"] - 1 # open of day t to open of day t+1
held = pos.shift(1) # decided after the close of t-1, filled at the open of t
traded = pos.diff().abs().shift(1)
return pd.DataFrame({"held": held, "net": held * open_ret - traded * cost}).dropna()
The two shift calls carry the no-look-ahead rule. A decision made from day t's close is filled at day t+1's open and earns the return from that open onward, and the trading cost lands on the day of the fill. Trading at the same close you used to decide is the most common way a backtest flatters itself.
COST folds commission, spread and slippage into one number per unit traded. Five basis points is an assumption, not a quote, so the next step tests several levels. Commission-free is not cost-free: Alpaca's paper trading docs say simulated fills ignore market impact, slippage due to latency, queue position, price improvement, regulatory fees and dividends.
Step 5: Metrics: CAGR, max drawdown, Sharpe and hit rate
def metrics(bt):
net, in_market = bt["net"], bt["held"] > 0
equity = (1 + net).cumprod()
return {
"cagr": equity.iloc[-1] ** (252 / len(net)) - 1,
"max_drawdown": (equity / equity.cummax() - 1).min(),
"sharpe": net.mean() / net.std() * np.sqrt(252),
"hit_rate": (net[in_market] > 0).mean(),
"exposure": in_market.mean(),
"trades": int((bt["held"].diff().abs() > 0).sum()),
}
runs = {
"buy_and_hold": pd.Series(1.0, index=px.index),
"rules_only": (states["long_term_trend"] == "up").astype(float),
"jev_gated": positions(answers, threshold=0.4),
}
print(pd.DataFrame({name: metrics(backtest(px, pos)) for name, pos in runs.items()}).T.round(3))
for c in (0.0, 0.0005, 0.001, 0.002):
print("cost", c, "sharpe", round(metrics(backtest(px, runs["jev_gated"], cost=c))["sharpe"], 2))
- CAGR: compound annual growth, using 252 trading days a year.
- Max drawdown: the worst fall from a peak, and the best test of whether you could live with the strategy.
- Sharpe ratio: mean daily return over its standard deviation, times the square root of 252, with a zero risk-free rate.
- Hit rate: the share of days in the market that made money. Read it next to the trade count.
- Exposure and trades: time invested and position changes, which drive costs.
The baselines matter more than any single number. Buy and hold shows what doing nothing earned; the rules-only run uses the same trend label without Jev, so its gap to the Jev-gated run is what Jev added or took away. Be as honest as one open-source Jev stock forecaster, whose author published a backtest with 54 of 120 next-day calls right; see Jev trading bots on GitHub.
Step 6: Walk-forward testing and threshold tuning
Every parameter you tune on the full history makes the backtest look better than the future will be. A walk-forward test tunes the threshold on a training window, tests it on the year after, then rolls forward, so every test year is out of sample. The code also keeps the final year out of everything and runs it once at the end.
GRID = (0.0, 0.2, 0.4, 0.6)
def walk_forward(px, answers, train_years=3, min_trades=6):
years = sorted(px.index.year.unique())
pos = pd.Series(np.nan, index=px.index)
picks = {}
for i in range(train_years, len(years)):
train = px.index.year.isin(years[i - train_years:i])
scores = {}
for t in GRID:
m = metrics(backtest(px[train], positions(answers[train], t)))
if m["trades"] >= min_trades:
scores[t] = m["sharpe"]
if scores:
picks[years[i]] = max(scores, key=scores.get)
test = px.index.year == years[i]
pos[test] = positions(answers[test], picks[years[i]])
live = pos.notna()
return backtest(px[live], pos[live]), picks
dev = px.index.year < px.index.year.max()
oos, picks = walk_forward(px[dev], answers[dev])
print(picks, pd.Series(metrics(oos)).round(3).to_dict())
final = backtest(px[~dev], positions(answers[~dev], picks[max(picks)]))
print("holdout:", pd.Series(metrics(final)).round(3).to_dict())
Rules that keep the tuning honest:
- Keep the grid small and fix it before you look.
- Demand enough trades in each training window, so one lucky trade cannot win.
- Prefer stable picks. A best threshold that jumps around each year is probably noise.
- Run the holdout once. Change things after seeing it and it is no longer a holdout.
- Count your attempts. The more variants you try, the more likely the best is luck.
- Test elsewhere: symbols and periods you did not tune on.
A threshold only means something if Jev's probabilities match outcomes in your market. Jev confidence scores explained shows how to check calibration by bucket.
From backtest to paper trading
A good backtest earns a paper trading test, nothing more. Keep the same feature code, labels, questions, threshold and pinned model, and run the strategy on live data with simulated fills for weeks. Paper fills will differ from the backtest's open prices, and that difference is information. Our Jev paper trading bot with Alpaca reuses the functions on this page, and how to build a Jev AI trading bot covers the wider design.
Want a second pair of eyes on a backtest, or a decision log your team can query in plain English? Get in touch.
Jev backtesting FAQ
Can you backtest a Jev strategy on historical data?
Yes. Jev judges whatever state you send, so you can rebuild each past day's state from data available that day and ask Jev about it now. Keep tickers and dates out of the state, cache every answer, and trade only at the next open. The result shows how today's model judges past situations, which is not the same as how it would have traded back then.
How much does a Jev backtest cost?
Very little. Jev costs $0.042 per 1M input tokens as of 18 October 2026, and output is free. With labeled states and a cache, a daily backtest needs only as many calls as there are distinct states, often a few dozen. Even without a cache, 2,500 trading days at about 300 tokens each is roughly 750,000 tokens, or about 3 cents.
Will a Jev backtest give the same result every run?
Only if you cache the answers and pin the model version. TypeSafe's docs do not promise identical probabilities for identical requests, and aliases such as jev-latest move to new versions. Store each answer with the versioned model ID that produced it, and treat a new version as a new experiment with its own cache and its own walk-forward test.
What confidence threshold should I use in a backtest?
Choose it with a walk-forward test, not by looking at the full history. Try a few values chosen in advance, require a minimum number of trades, and prefer a threshold that holds up across most windows. For a two-option choice, confidence equals twice the top probability minus one, so 0.4 means the top option needs at least 70 percent.
What is look-ahead bias in a trading backtest?
It means letting information from the future leak into a past decision. Common forms are trading at the same close used to compute a signal, scaling features with statistics from the whole period, and testing only on today's index members. Shift positions by one bar, compute features with past-only windows, and fix bucket limits in advance.
How long should I paper trade after a backtest?
Long enough to see the strategy trade many times in conditions it was not tuned on. For a daily strategy that usually means weeks to months. Compare paper fills, trade counts and drawdowns with the backtest, and write down in advance what result would make you stop. No fixed period guarantees that a strategy is safe.
Sources
- TypeSafe docs: Models, aliases and pricing
- TypeSafe docs: Python SDK basic usage
- TypeSafe docs: Python SDK changelog
- TypeSafe docs: Confidence
- TypeSafe docs: Jev 1.13 jaggedness
- Alpaca docs: About Market Data API
- Alpaca docs: Market Data FAQ
- Alpaca docs: Paper Trading
- alpaca-py docs: Market Data
- pandas docs: DataFrame.shift
Loading comments...