Writing a strategy¶
A strategy is a directory with strategy.py and strategy.yaml, registered in
signalquarry.toml under [strategies] modules.
from decimal import Decimal
from signalquarry.sdk import Ctx, Decision, Field, Params, strategy, ta
class P(Params):
symbol: str = "SPY"
period: int = Field(200, ge=20, le=400) # bounds document the search space
weight: Decimal = Field(Decimal("0.95"), ge=0, le=1)
@strategy(params=P, lookback=lambda p: p.period)
def decide(ctx: Ctx, p: P) -> Decision:
close = ctx.bars(p.symbol).close # read-only; [-1] = last COMPLETED session
if close[-1] > ta.sma(close, p.period)[-1]:
return Decision.target({p.symbol: p.weight}, "PRICE_ABOVE_SMA")
return Decision.target({}, "PRICE_BELOW_SMA")
The context¶
ctx.bars(symbol)returns exactlylookbackcompleted sessions (open,high,low,close,volume,sessionsas read-only numpy arrays), split-adjusted to the last completed session. There is no "today" bar.ctx.positions,ctx.weights,ctx.cash,ctx.equitydescribe the account before the decision;ctx.stateis what you returned last time.ctx.decision_sessionis the session the decision will trade in.
Decisions¶
Decision.target({symbol: weight}, "CODE", state=...)— weights are fractions of equity, long only, each ≤limits.max_weight_per_symbol, sum ≤ 1. An unchanged, fully executed target does not trade again.Decision.hold("CODE", state=...)— keep holdings.Decision.unavailable("CODE")— no decision (the engine already returnsINSUFFICIENT_HISTORYandSTALE_OBSERVATIONSfor you).
Every reason code you return must be declared in strategy.yaml; undeclared
codes stop the run (REASON_CODE_UNDECLARED). State must be JSON-serializable
and at most 16 KB.
Factor portfolios¶
sqy init NAME --demo --kind factor adds a registered volume-shock demonstration
factor. Evaluate it offline with sqy factor evaluate --factor volume-shock --synthetic;
synthetic diagnostics provide no evidence about real markets.
Use sqy factor report --factor volume-shock --synthetic to run the same evaluation
and write a readable report.md, ic.svg and quintiles.svg in a new directory
under .signalquarry/factor_reports/. It accepts the same options as factor evaluate,
including --symbols, --seed, --planted-ic, or a recorded --dataset-id with
dated --universe-manifest paths. The report includes the factor's hypothesis,
diagnostics by horizon, chronological-block IC and input identities. Recorded-data
diagnostics remain unverified; neither mode records a trial, assigns an evidence
grade or accesses a holdout. Long-short spreads are statistics, not executable portfolios.
Import registered factor functions into a normal @strategy and return the
composite through factor_portfolio. It reads only the completed bars in
Ctx, standardizes each factor cross-sectionally, and creates an ordinary
Decision.target:
from signalquarry.sdk import Ctx, Decision, FactorInput, Params, factor_portfolio, strategy
from mylab.factors.momentum import P as MomentumParams, momentum
from mylab.factors.value import P as ValueParams, value
class PortfolioParams(Params):
period: int = 60
@strategy(params=PortfolioParams, lookback=lambda p: p.period)
def decide(ctx: Ctx, p: PortfolioParams) -> Decision:
return factor_portfolio(
ctx,
(
FactorInput("momentum", momentum, MomentumParams(period=p.period)),
FactorInput("value", value, ValueParams(period=p.period)),
),
top_quantile=0.2,
max_weight_per_symbol=0.05,
turnover_buffer=0.05,
)
This example assumes both factor parameter models have a period field.
Set the strategy lookback to at least the longest factor lookback. The factor
parameter models and their values are part of the strategy package's hashed
code; keep them fixed for a run and express alternatives as distinct trials.
The default gives each factor equal weight after cross-sectional z-scoring.
FactorWeightSnapshot can supply dated weights computed from earlier
walk-forward windows: known_at must precede effective_from, and the engine
uses the latest row effective by the current decision. The helper checks these
dates but does not prove the schedule's provenance. Missing scores are not
imputed; symbols with no composite score are ineligible. Existing positions
inside the wider exit rank band are retained to reduce turnover.
The helper returns target weights only. The standard engine and paper planner
apply the strategy's declared costs, minimum order notional, per-symbol limit,
evidence gates, and human paper-arming controls. Factor diagnostics marked
unverified remain descriptive and do not become accepted evidence through
composition.
Rules sqy check enforces¶
- Imports:
signalquarry.sdk,numpy,pydantic, pure standard-library modules (math,decimal,statistics,collections,dataclasses,enum,functools,itertools,operator,typing) and your own package. - No clock, randomness, files, network, environment or mutable globals.
- Deterministic: the same inputs give the same decisions.
- Look-ahead: perturbing every bar after the cutoff must not change decisions.
strategy.yaml¶
Everything that affects results lives here and is hashed into the
configuration_hash: hypothesis (statement and falsification), data
(symbols, feed, staleness), account (cash or margin, initial cash),
execution (next open, sizing, costs, minimum order), params, reason_codes,
limits, evaluation (holdout months, walk-forward windows, trial budget) and
benchmark (a symbol every run is compared with as buy-and-hold; see
Evidence). Run sqy schema for the bundled schemas.
Execution model¶
Decisions are made before the open from bars through the previous session and executed at that session's open: sells before buys, symbols sorted, sized from prior-close marks. Cash accounts spend settled cash only (T+2 before 2024-05-28, T+1 after). Costs are basis points plus per-share fees. Splits adjust holdings; dividends are credited on the payable date.
Sell orders can also pay regulatory fees: costs.sell_bps (on sale notional, SEC
Section 31 style), costs.sell_per_share and costs.sell_per_order_max (FINRA TAF
style). They default to zero because the rates change; set the ones in force for the
period you study.
For capacity-sensitive strategies, execution.fill: {model: volume_cap,
max_volume_fraction: 0.01} fills at most that share of each session's volume per
symbol (equity only); the rest is retried next session with a VOLUME_CAPPED warning.
The cap uses the fill session's volume: it models capacity, it never feeds a decision.
Paper venues fill whole orders, so check --parity compares without it.
Options strategies (single leg)¶
kind: options_single_leg specs add an options section (underlyings, quote rules,
tick, per-contract fee, spread haircut, entry cutoff). The strategy is decorated with
@options_strategy and returns selectors, never contract symbols:
from signalquarry.sdk.options import OD, OptionsCtx, Strike, options_strategy, sell_call, sell_put
@options_strategy(params=P, lookback=lambda p: p.trend_sessions)
def decide(ctx: OptionsCtx, p: P):
leg = ctx.leg("QQQ")
if leg is not None:
if leg.captured_fraction is not None and leg.captured_fraction > p.take_profit:
return OD.close("QQQ", "TAKE_PROFIT")
return OD.hold("HOLD_LEG")
if ctx.wheel("QQQ").state == "flat":
return OD.open(sell_put("QQQ", dte=(7, 14), strike=Strike.otm("0.02")), "SELL_PUT")
return OD.open(sell_call("QQQ", dte=(7, 14), strike=Strike.otm("0.02")), "SELL_CALL")
The engine owns the wheel state machine (flat → short_put → long_shares → covered_call),
resolves selectors with one resolver shared by simulation and paper (strike on the
out-of-the-money side, then the earliest expiry; limit at the bid floored to the tick)
and manages expiry and assignment. The backtest is a low-evidence simulator: option
prices come from a Black-Scholes model with a spread, fills pay a spread haircut and a
per-contract fee, decisions happen at the open and near the close, and early assignment
is not modelled. Results are graded low_evidence_options; there is no holdout gate (so
spec freeze seals no holdout for options and keeps all of the scarce history), and
claims stay at walk_forward or below until a paper forward test passes G5.
sqy init <dir> --kind options scaffolds a project around the reference wheel
(examples/wheel_reference/): cash-secured puts, covered calls after assignment, trend-aware
strikes, a take-profit buy-back and at most one entry per week.