Account Model
Every QuantCraft strategy runs against a built-in paper trading account. The account is what tracks your cash, holds your open positions, books realized profit and loss when you close trades, and computes performance metrics at the end of a backtest.
You don't construct it yourself in normal use — the backtest engine creates it from the Run backtest modal Simulation tab (starting balance, commission / slippage) and assigns it to quantcraft.process.runtime.account before on_init() runs. Optional default SL/TP and risk-based sizing can be wired via config / .qcs keys when present; the modal does not currently expose SL/TP / risk inputs. Callbacks receive market data as described in Strategy lifecycle and OHLCV and bar data; metrics show up in Test results.
Quick start
import quantcraft.process.runtime as rt
def on_init():
pass
def on_bar(bar_index, bar, fundamentals=None, symbol=None):
account = rt.account
px = float(bar["close"])
snap = account.open_trade("AAPL", "long", 10, px)
if snap["open_positions"]:
pid = snap["open_positions"][0]["id"]
account.close_trade(pid, px + 1.0)
def on_finish(bars, symbol=None, bars_by_symbol=None):
last_px = float(bars[-1]["close"])
metrics = rt.account.refresh_metrics({"AAPL": last_px})That's the full pattern: open a trade, store the position id, close it later, and (optionally) refresh metrics at the end.
What you read from the instance: account.cash, account.balance, account.realized_pnl, account.closed_trades and account.open_positions_payload(). There is no account.open_positions attribute — open legs come from the snapshot (snap["open_positions"]) or open_positions_payload().
Forward runs place real orders. In a chart forward test or bulk run,
runtime.accountis a live broker account — aPaperAccountsubclass with the same API that sends real orders to your connected broker account (paper or live), including bracket SL/TP orders for equities. Treatopen_trade/close_tradethere as real trades. Trading involves risk of loss.
What PaperAccount does
The account simulates a simple cash trading account:
- Tracks starting balance, cash, balance, and equity.
- Holds long and short positions in the same account.
- Books realized P/L when you close a trade and tracks unrealized P/L for open positions.
- Maintains an equity history that is appended on every price update.
- Optionally computes a full set of performance metrics (Sharpe, Sortino, drawdown, CAGR, etc.).
What it does not model: margin, borrow cost, or automatic SL/TP execution. Stored SL/TP values are just numbers — your strategy code is responsible for checking prices and calling close_trade when a stop or target is hit. Commission and slippage apply when models are configured on the Simulation tab — see below.
Dividends and splits are modeled when calendar data is enabled for the run. On the split's effective date, share count is multiplied by the ratio and cost basis divided by it (equity curve stays continuous). On the ex-date, qty × amount is credited to cash for longs and debited for shorts. Dividend cash flows into balance and equity but not into realized_pnl — read total_dividend_income and total_splits_applied from account.refresh_metrics(). See Calendar data.
Commission and slippage are configured in the Run backtest modal Simulation tab. When enabled, fills are adjusted automatically; you do not set these in Python. The Run modal defaults to per share commission at $0.001 per share with a $0 minimum, and volume share slippage. (A PaperAccount you construct yourself defaults to no commission and no slippage.)
Configuring the account
You configure the account on the Simulation tab of Run backtest:
| Setting | What it does |
|---|---|
starting_balance | Cash the account starts with. |
| Commission / slippage models | How fills are adjusted — see below. |
Optional default SL/TP and risk sizing (sl_pct / sl_dollars, tp_pct / tp_dollars, risk_pct_per_trade / risk_fixed_per_trade) can still be applied from .qcs / config helpers when present, but the Run backtest modal does not currently expose those inputs. When they are set:
- Percent fields accept either whole-percent values like
2or fractions like0.02— both mean the same thing. - For longs, the stop is below entry and the target is above entry. Shorts invert that.
- Defaults apply to every new trade unless you override them per call.
For notebooks or tests only (outside the IDE backtest flow), you can construct an account manually:
account = PaperAccount(
100_000,
sl_pct=2,
tp_pct=4,
risk_pct_per_trade=1,
slippage_model=None, # optional; defaults to no slippage (IDE engine wires from Simulation tab)
commission_model=None, # optional; defaults to zero commission (IDE engine wires from Simulation tab)
equity_history_cap=None, # optional; keep only the newest N equity_history points
)Commission and slippage (Simulation tab)
The Simulation tab in the Run backtest modal configures how fills are adjusted for trading costs. Settings apply automatically to runtime.account — you do not configure them in Python. See Backtests and Run presets (.qcs) for saving these options.
Commission models
| Model | Parameters | Behaviour |
|---|---|---|
| Per share (default) | Cost per share ($), minimum per trade ($) | max(qty × cost, min) when minimum > 0 |
| Per trade | Flat cost per order ($) | Same fee regardless of quantity |
| Zero | — | No commission — useful for testing pure alpha |
Slippage models
| Model | Parameters | Behaviour |
|---|---|---|
| Volume share (default) | Volume limit (default 0.025), price impact (default 0.1) | Impact scales with (qty / bar_volume)²; buys fill higher, sells fill lower. On opens only, volume_limit also silently shrinks the quantity to volume_limit × bar_volume — check the returned snapshot for the size you actually got. (If that leaves nothing, open_trade raises qty after volume_limit cap is not positive.) Closes are never capped. Zero / missing bar volume → no impact. |
| Fixed spread | Total spread per share ($) | Half-spread applied on each fill leg |
| Zero | — | Fill at exact requested price |
Closed trade records include commission, slippage_cost, raw_entry_price, and raw_exit_price. Stored entry_price / exit_price are fill prices after slippage. account.refresh_metrics() exposes total_commission and total_slippage_cost.
Positions
A position represents one open trade in a single symbol. You don't construct positions directly — they are created by open_trade(...) and removed by close_trade(...), which can also close part of a position and leave the remainder in place.
Opening a trade
snap = account.open_trade(
symbol, # "AAPL"
side, # "long" or "short"
qty, # integer or float quantity
entry_price, # float, the requested price (slippage/commission applied)
sl_price=None, # optional stop price (overrides default sl_pct/sl_dollars)
tp_price=None, # optional target price (overrides default tp_pct/tp_dollars)
position_id=None,
)The call returns a snapshot of the account (see below). The newly opened position appears in snap["open_positions"] — grab its id if you want to close it later by id. The stored entry_price is the fill after slippage; the requested price is kept as raw_entry_price.
A long open is rejected with insufficient cash for long when qty × fill_price + commission (fill price after slippage) exceeds cash. The returned snapshot marks that symbol at the fill price.
Sizing by risk
If you want the account to size the trade based on your risk budget (one of risk_pct_per_trade or risk_fixed_per_trade) and a stop distance, use:
qty = account.position_size_for_risk(entry_price, stop_price)This requires that exactly one risk mode is configured and that the stop distance isn't zero. By default, risk_pct_per_trade uses a percent of cash; pass equity_for_risk=... to size against equity instead.
Closing a trade
account.close_trade(position_id, exit_price) # close the whole position
account.close_trade(position_id, exit_price, qty=4) # scale out 4 units, keep the restThe full signature is close_trade(position_id, exit_price, *, qty=None) — qty is keyword-only.
open_trade / close_trade take a requested price, then apply the run’s slippage and commission models. Stored exit_price is the fill; the requested price is kept as raw_exit_price. A common backtest simulation pattern: request the bar's closing synthetic tick from on_tick:
def on_tick(bar_index, tick_in_bar, price, bar, fundamentals=None, calendar=None, symbol=None):
if tick_in_bar == 3:
account.close_trade(pid, price)On forward runs, use the live price from each broker quote in on_tick instead of checking tick_in_bar.
Partial closes (qty=)
Passing qty closes only that many units and leaves the remainder open in place — same position id, same entry_price, same entry_time — so the remaining position keeps its cost basis and holding period:
pid = account.open_positions_payload()[0]["id"]
account.close_trade(pid, px, qty=4) # scale out 4 units, keep the restA partial close's record in closed_trades carries partial: True and remaining_qty.
- Costs are charged on the units that actually traded, and the lot's open commission is split pro-rata, so a sequence of partial closes charges exactly what one full close would have.
- Omit
qty— or pass a value at or above the position size — for a full close. - The remainder stays a normal open position: it keeps being marked each step and shows up in
snap["open_positions"]under the same id, so you can scale out again later.
You can also close in bulk by symbol. symbol= is required in multi-symbol runs so you do not accidentally close positions on every ticker at once:
account.close_all_longs(exit_price, symbol="AAPL")
account.close_all_shorts(exit_price, symbol="AAPL")Other helpers
| Method | Purpose |
|---|---|
open_positions_payload(prices=None) | Open legs as dicts, each with last_price and unrealized_pnl at prices (default: last tick). |
open_position_symbols() | Set of symbols with at least one open leg. |
position_unrealized_pnl(pos, prices) | Open P/L of one OpenPosition. |
apply_split(symbol, ratio, *, when=None) / apply_dividend(symbol, amount_per_share, *, estimated=False, when=None) | Corporate actions — the engine calls these when calendar data is on; see Calendar data. |
corporate_actions | Record of the splits / dividends applied so far. |
set_bar_volume(symbol, volume) | Bar volume used by volume-share slippage; the engine sets it each bar. |
Exit price vs indicators: Pass the price you intend to target (e.g.
float(bar["close"])fromon_bar); the engine still applies slippage/commission. It does not rewrite your request to an SMA or other indicator. In backtest,tick_in_bar == 3inon_tickis a synthetic closing tick (bar OHLC simulation, not real tick data). On forward runs, close using the livepricefromon_tickon each broker update.
Cash, balance, equity, and PnL
These are the core money-tracking fields available on the account and on every snapshot:
| Field | Meaning |
|---|---|
starting_balance | The cash the account started with. |
cash / balance | Settled cash after opens/closes. Long opens debit qty × fill_price + commission; shorts credit qty × fill_price − commission. Stored entry_price / exit_price are fill prices after slippage. |
equity | Mark-to-market total: cash + Σ(long qty × mark) − Σ(short qty × mark). |
unrealized_pnl | Sum of P/L on open positions, valued at the current marks. |
realized_pnl | Cumulative profit/loss from closed trades. |
How marks are updated:
- The engine calls
tick(prices, time=...)on the account on every simulated price step, soequity,unrealized_pnl, and the equity history stay current automatically. - If a symbol's mark price is missing from the
pricesmap, that position is valued at its entry price — unrealized P/L for that leg is 0 until you pass a real quote. close_tradecarries the previous marks forward and stamps the closed symbol at its exit fill, so positions left open — including the remainder of a partial close — stay marked.refresh_metrics()with no argument reuses the marks from the most recent snapshot (open_trade/close_trade/tick), and only falls back to last equity / cash when that map is empty.
Inside
on_barandon_tick, you do not need to callaccount.tick(...)yourself — the engine drives it for you. It ticks before eachon_tick, buton_barruns before that step's ticks, so inon_barthe latest marks are still the previous bar's close. A redundanttickappends an extraequity_historypoint. Prefer passing explicitlast_pricestorefresh_metrics({symbol: last_px})inon_finishwhen anything might still be open.
Short trades and cash
Both longs and shorts are supported in the same account:
- Short open — proceeds increase cash (minus commission when configured).
- Short close (buy to cover) — reduces cash.
There is no margin model or borrow cost, so shorts are a pure cash-and-mark simulation (plus commission/slippage when models are set).
Snapshots
Every call to open_trade, close_trade, and tick returns the same snapshot dictionary describing the state of the account right after the call. Useful keys:
| Key | What it contains |
|---|---|
starting_balance | Initial cash. |
cash / balance | Current cash. |
equity | Current mark-to-market equity. |
unrealized_pnl | P/L on open positions at current marks. |
realized_pnl | Cumulative realized P/L. |
open_positions | List of open position dicts: id, symbol, side, qty, entry_price, sl_price, tp_price, raw_entry_price, commission_at_open, entry_time (may be null). There is no per-leg P/L field on open position rows — read unrealized_pnl from the snapshot for aggregate floating P/L. |
closed_trades | List of closed trades: position_id, symbol, side, qty, entry/exit prices, realized P/L, commission (includes this close's share of the opening commission), slippage_cost (entry + exit slippage), raw_exit_price, optional raw_entry_price, and entry_time / exit_time; partial closes add partial and remaining_qty. Also available as account.closed_trades on the instance. |
tick_prices | Copy of the price map used for that snapshot. close_trade carries the previous marks forward and stamps the closed symbol at its exit fill, so open legs stay marked across a close. |
There is no account.get_open_positions(symbol) API. Filter snap["open_positions"] (or account.open_positions_payload()) by uppercase symbol, or read account.closed_trades for closed history.
This makes it easy to inspect the account in your strategy:
snap = account.open_trade("AAPL", "long", 10, px)
print("equity now:", snap["equity"])
print("open positions:", len(snap["open_positions"]))Performance metrics
When the run is finishing, you can ask the account to compute a full set of metrics:
def on_finish(bars, symbol=None, bars_by_symbol=None):
last_px = float(bars[-1]["close"])
metrics = account.refresh_metrics({"AAPL": last_px})The argument is a {symbol: last_price} map used as the final mark so open positions are valued correctly. Use the bars list passed into on_finish (oldest first) to get the last close — this is the canonical full-history series.
You can also pass two optional arguments:
risk_free_rate=0— used by Sharpe/Sortino-style ratios.calmar_annualized_return=None— overrides the annualized return used by Calmar.
Available metrics
refresh_metrics(...) returns a dictionary that includes:
| Group | Fields |
|---|---|
| Returns | total_pnl, total_return, cagr |
| Risk | max_drawdown, volatility, risk_adjusted_return |
| Ratios | sharpe_ratio, sortino_ratio, calmar_ratio, profit_factor |
| Trade stats | average_trade_expectancy, average_win, average_loss, total_commission, total_slippage_cost |
| Exposure | current_exposure, average_exposure, max_exposure |
| Corporate actions | total_dividend_income, total_splits_applied |
Notes:
- Trade-based ratios use per-trade return defined as
realized_pnl / starting_balance. total_returnandcagruse current equity vs. starting balance.- For meaningful CAGR / Calmar, the engine needs calendar information — the engine already passes timestamps when it ticks the account, so you generally don't need to do anything special.
- Some ratios may return
infwhen the denominator is zero and performance is favorable (for example, no losing trades).
The metrics are also surfaced in the Test Results view of the editor and in the structured result payload of the run.
Putting it together
Typical usage inside a strategy:
- Configure starting balance and commission / slippage on the Simulation tab (optional SL/TP / risk via
.qcs/ code when supported). - Open trades from
on_bar(or fromon_tickfor intrabar fills) usingaccount.open_trade(...)— store the returnedidif you'll need it. - Close trades with
account.close_trade(position_id, exit_price)— orqty=to scale out and leave the rest of the lot open. - Read
snap["equity"],snap["cash"],snap["unrealized_pnl"], etc. whenever you want to inspect the account. - In
on_finish, callaccount.refresh_metrics({symbol: last_price})to populate performance metrics for the result.
That's the whole account model — small surface area, but enough to simulate longs, shorts, cash flows, P/L, and a complete metrics set for any strategy you can express with the lifecycle callbacks.
