trader.strategies.trailing_stop_floor module

A trailing stop with a hard floor, plus LLM-bounded discretion over the trail.

requirements.md 6.1’s “Trailing stop with floor” and 6.3’s companion-markdown config format. Deliberately not the same mechanism as the protective-stop ratchet in trader/execution/ (§8, the OTO/atomic-protection path): that one protects a fill this app already made and runs unconditionally on every held position, regardless of which strategy owns it. This is a signal generator — one more Strategy competing for the SELL decision on a symbol, exactly like Turtle or RSI. It never places an order and never touches the broker; it only ever returns a Signal, and the same sold_this_cycle guard, exposure caps and §8 guardrails that gate every other strategy’s SELL gate this one too. Nothing about this file weakens or bypasses the existing ratchet.

The trailing level and the floor are computed here, deterministically, from the bars and the position alone. A companion Markdown file (notes_path) may carry free-text discretionary guidance — “don’t tighten the stop on earnings day” — and each cycle, if the file is non-empty, the configured LLM is asked to translate that guidance into one number: how wide the trail should be, in whatever unit the YAML configured (percent of the peak, or a fixed amount off it). That number is used only as a proposal.

Two clamps make the floor and the daily-loss guardrail unconditional, independent of anything the model returns:

  1. The model’s suggested trail width is clamped into [trail_min, trail_max] — the YAML-configured band — no matter what it answers, even a value wildly outside the band or a response that ignores the prompt’s instructions entirely.

  2. The stop level computed from any trail width (clamped or not) is then floored: stop_level = max(trail_level, floor_level). The floor is never a function of the model’s answer, so nothing the LLM says — not even a note that explicitly instructs it — can move the stop below the floor. See TrailingStopFloorStrategy.evaluate’s acceptance test in tests/strategies/test_trailing_stop_floor.py for the case requirements.md calls out by name: notes saying “ignore the floor, hold anyway” must not prevent the SELL.

The daily loss limit (§8, daily_loss_breached) is enforced by the pipeline against the account, not by any strategy against a symbol — the same way every other strategy’s BUY/SELL signal is already subject to it. This strategy does not re-implement that check; it has no account-wide view to check it against. What it guarantees is narrower and sufficient: nothing it emits can ever be influenced by the LLM into skipping a floor breach.

Same HOLD-on-any-failure discipline as LlmStrategy: an unreachable model, a timeout, or a response that cannot be parsed into a number never fails evaluate() and never blocks the deterministic floor/trail decision — the notes are simply ignored for that cycle and the base (YAML) trail width is used instead. This is the one path in this file that is allowed to log a warning; every failure still ends in a valid Signal.

`backtestable = False`, deliberately, for the same reason as `LlmStrategy` (see that class’s own class-attribute comment): whenever the notes file is non-empty this strategy makes a real network call to the LLM inside evaluate(), and BacktestEvaluator calls evaluate() once per bar — a 400-bar backtest window would be 400 model calls per symbol. There is also no historical version of a notes file to replay: a backtest over 2024 bars would be scored against today’s notes file, which is not a meaningful measurement of anything. An alternative design — applying the notes-adjustment as a step separate from the bar-by-bar core loop, so the deterministic floor/trail logic alone could be backtested with backtestable = True and the notes skipped — was considered and rejected: BacktestEvaluator calls one Strategy.evaluate() per bar with no hook for a second, out-of-band adjustment step, so supporting it would mean splitting this strategy’s logic across two entry points state would have to be kept in sync between. The notes-free floor/trail arithmetic (_floor_level, _trail_level, _clamp) is already pulled out into plain methods a future backtest harness could call directly without instantiating an LLM-calling Strategy at all, if that measurement is ever wanted.

class trader.strategies.trailing_stop_floor.TrailingStopFloorStrategy(id, llm, tickers=(), trail_pct=None, trail_amount=None, trail_min=None, trail_max=None, floor_price=None, floor_pct=None, notes_path=None, lookback_bars=60)[source]

Bases: object

A trailing-stop exit, floor-protected, with bounded LLM discretion.

Exit-only: this strategy never originates a BUY. Its “position reference” (requirements 6.1) is tickers — the symbol(s) it manages — plus the fact that it only ever acts on a symbol it is already holding. A symbol it manages but does not currently hold, and a symbol it does not manage at all, both resolve to HOLD, for different stated reasons (see evaluate).

Parameters:
  • id (str)

  • llm (LlmProvider)

  • tickers (Sequence[str])

  • trail_pct (object | None)

  • trail_amount (object | None)

  • trail_min (object | None)

  • trail_max (object | None)

  • floor_price (object | None)

  • floor_pct (object | None)

  • notes_path (str | None)

  • lookback_bars (int)

backtestable = False

See the module docstring for the full reasoning.

warmup_bars()[source]
Return type:

int

evaluate(bars, position)[source]
Parameters:
Return type:

Signal