Skip to main content

Strategies

A Strategy (tradeflow/strategies/base.py) is responsible for three things and no more:

  1. Indicatorsprocess_data(df) returns the OHLCV frame plus the columns it needs.
  2. Convictioncalculate_scores(df) returns one continuous, signed score per bar (positive = bullish, negative = bearish, magnitude = strength). This is the strategy's single source of truth.
  3. Sizing & riskcalculate_position_size, check_exit_conditions, validate_signal.

It does not fetch data, place orders, or compute portfolio metrics.

One score, two consumers

A strategy defines only the score. Everything else is derived from it, so there is no parallel decision path to keep in sync:

  • The trade clock gets its discrete BUY/SELL/HOLD from generate_signals(df), which the base class implements by walking the score with hysteresis (see below). Strategies do not override it.
  • The alpha layer (continuous alphas) reads the same score as a cross-sectional conviction and scales it into a residual-return forecast.

Deriving the signal from the score

generate_signals tracks the desired position direction implied by the score:

enter long when score crosses above enter_long
exit long when score falls to/below exit_long → CLOSE_BUY
(short side mirrors it, for strategies with LONG_ONLY = False)

The bands come from signal_thresholds() (default: pure sign — long while score > 0). A strategy with asymmetric entry/exit — one that enters when RSI is oversold but holds until it's overbought — overrides signal_thresholds() to set a wide hold band. Entries are edge-triggered (emitted on the crossing bar); while a direction is held the bar emits HOLD, and the engine dedupes against the open position.

Edges, and what live mode adds

An edge says change. That is enough for a backtest, where the book is derived from the same signals and so can never disagree with them. Live it is not: the crossing bar can be missed — rejected by a quality guard, lost to a dropped stream, consumed by a restart, or simply inside the warm-up history — after which the score still says "should be long" while every bar emits HOLD, and the position is never opened. A missed exit is worse: a real position that nothing will close.

So process_bar also compares the direction the score implies against the position book (which LiveTrader keeps synced with broker truth) and re-states any difference. Where an edge says change, this says what should be true now, and the loop converges on the intended book instead of depending on having caught one bar.

Entries are gated by reaffirm_entries (default on — a trend-follower started mid-trend should hold the trend). Exits never are: declining to open a position is a preference, declining to close one the strategy no longer wants is a stuck position. See live trading for the operational side.

The signal vocabulary

The derived signals are plain strings, defined once in tradeflow/strategies/signals.py so every layer agrees:

BUY, SELL # open a position
CLOSE_BUY, CLOSE_SELL # close a position
HOLD # do nothing

Keeping them central avoids the classic bug where one layer emits "buy" and another checks for "BUY".

Lifecycle hooks

MethodWhenPurpose
calculate_required_lookback()constructionbars needed before indicators are valid
initialize()start of a runvalidate parameters / relationships
process_data(df)each batch/baradd indicator columns
calculate_scores(df)after processingproduce {timestamp: score} (the one thing you implement)
signal_thresholds()per signal derivationoptional: asymmetric entry/exit bands
generate_signals(df)after scoring (base class)derive {timestamp: signal} from the score
process_real_time_data(...)live, per barfold a streamed bar into a rolling buffer and emit the latest signal

Position sizing

calculate_position_size(capital, price) derives a size from risk_per_trade and stop_loss, then clamps it to the configured limits (max_position_size, max_total_risk). It is the smallest of the three constraints — risk target, per- position notional cap, and total-risk cap.

The total-risk clamp here applies the whole book's budget to one position, since sizing has no view of what is already open: it only answers "could this position alone exhaust the budget?". Enforcing the budget across the book — and the separate max_gross_exposure notional cap, which no single-position sizing call can meaningfully apply — is the engine's job. What each fraction actually measures is spelled out under what max_total_risk caps.

Parameters & validation

Each strategy declares PARAM_RANGES with min/max/step/default/type. The base class coerces and range-checks every supplied value at construction, so an out-of-range parameter fails fast. step also lets the optimizer search the space.

A relationship between parameters — a fast period that must stay below the slow one — goes in PARAM_CONSTRAINTS beside the ranges, not in initialize(). The base class enforces it at construction either way, but only a declaration can be read by the sampler, and the sampler is the half that matters: a combination it draws gets evaluated, and an evaluated combination is a journaled trial that raises the deflation bar for every future candidate in the family. See constraints between parameters.

The bundled strategies

One ships, built on the pure indicators:

--strategyStyleScore (the one decision)
demo_trendTrend (daily, long-only)Normalized EMA gap (fast − slow) / slow — sign crossings are the golden / death cross.

It lives in tradeflow/demo/ rather than tradeflow/strategies/, and registers through the same entry-point group a private pack uses — so the engine discovers its own demonstration by the path yours will travel, and that path is exercised by every install rather than only by a test fixture.

Deliberately minimal, and deliberately alone: a second shipped strategy would be a second idea nobody chose, and the walk-forward scorecard is there to discriminate between your candidates. example is a real pack with a long-only and a long/short strategy to read; see Extending.