Skip to content

Enable per-symbol identity injection so signal-file / symbol-aware strategies (e.g. DB_B4) run in the engine #219

Description

@zachisit

Summary

The engine has no way to tell a strategy function which symbol it is currently looking at. This blocks an entire class of strategies the research team keeps producing — signal-file replays (e.g. DB_B4 in private PR #31) and symbol-conditional logic (e.g. spy_tlt_seasonal_rotation) — because both read df.attrs["symbol"], which the engine never sets. Dropped into main as-is, these plugins silently emit zero signals (or mis-branch) with no error.

This ticket adds first-class per-symbol identity injection into the strategy-dispatch path so these strategies run correctly, and documents the convention so future submissions rely on a supported API instead of an undocumented one.

Discovered while reviewing private-strategies PR #31 (Double Bottom B4 WF). The strategy's own numbers reconcile perfectly, but it was validated on the team's internal engine fork; it cannot run on this repo's engine without the capability below.


User Story

As a strategy author submitting a signal-file or symbol-aware plugin,
I want my strategy function to know which ticker's DataFrame it is being handed,
so that I can look the symbol up in a precomputed signal file (or branch on it) and have the strategy produce correct signals when run in this repo's engine — not silently zero.

Acceptance (author's view): a plugin can obtain the current symbol through a documented, stable API and, when I drop it into custom_strategies/, it fires the trades it fired on the research fork — no engine edits required by me.


Problem Detail

The per-symbol dispatch loop in run_single_simulation:

# main.py ~97-119
base_signals_with_dfs = {}
for symbol, df in portfolio_data.items():
    kwargs = {}
    # ... dependency injection (spy/vix) ...
    if strategy_params:
        kwargs.update(strategy_params)
    # ...
    base_signals_with_dfs[symbol] = logic_func(df.copy(), **kwargs)   # <-- symbol never passed
  • symbol is right there as the loop variable but is never passed to logic_func — not via **kwargs, not via df.attrs.
  • Grep confirms there is no .attrs write anywhere in main.py, helpers/, or services/.
  • Affected plugins already in the corpus:
    • custom_strategies/private/promotions/DB_B4/db_b4_strategy.py:91sym = str(df.attrs.get("symbol", ""))_SIGNALS.get("")None → all-zero Signal.
    • custom_strategies/private/spy_tlt_seasonal_rotation.py:27symbol = df.attrs.get("symbol", "").upper(); the if symbol == "TLT": branch never fires, so TLT is silently treated like SPY.

Scope

In scope (this ticket):

  1. Inject the current symbol into every strategy call via kwargs["symbol"] (the documented, preferred API going forward).
  2. Also stamp df.attrs["symbol"] on the frame handed to the strategy, so existing attrs-based plugins (DB_B4, spy_tlt) work without edits.
  3. Document the convention in helpers/registry.py and CLAUDE.md.
  4. Tests (below).

Out of scope (separate follow-ups, DB_B4-reproduction-specific, not needed to run new strategies):

  • data_provider="merged" (Norgate+Polygon merged corpus) — needed to reproduce DB_B4's exact P&L, not to run signal-file strategies generally.
  • PIT-aware vintage/renamed-ticker loader.
  • Vendoring the research.structural_followthrough modules used to regenerate signal files.

These should be tracked as their own tickets if/when we decide to reproduce DB_B4 end-to-end in-repo.


Implementation Plan

1. Inject symbol in the dispatch loop — main.py

In run_single_simulation, inside for symbol, df in portfolio_data.items()::

for symbol, df in portfolio_data.items():
    kwargs = {"symbol": symbol}          # NEW: preferred, explicit API

    # ... existing dependency injection & params merge unchanged ...

    df_for_logic = df.copy()
    df_for_logic.attrs["symbol"] = symbol   # NEW: back-compat for attrs-based plugins
    base_signals_with_dfs[symbol] = logic_func(df_for_logic, **kwargs)

Notes:

  • Every existing strategy takes (df, **kwargs) and ignores unknown keys, so adding symbol to kwargs is backward compatible — verified against the registry contract in helpers/registry.py.
  • Stamping on the copy (not the shared portfolio_data[symbol]) keeps the global data immutable and is process-safe under the existing multiprocessing model.
  • Do the same injection anywhere else a strategy logic_func is invoked (audit for a second call site; currently only this loop).

2. Document the convention

  • helpers/registry.py module docstring + register_strategy docstring: add a short "Symbol identity" note — "the engine always injects the current ticker as kwargs['symbol'] (and mirrors it on df.attrs['symbol']); read kwargs.get('symbol') for symbol-aware or signal-file strategies."
  • CLAUDE.md → "Adding a Strategy (Plugin System)": document kwargs["symbol"] as the supported way to get the current ticker, with a minimal signal-file example.

3. (Optional, nice-to-have) Reusable signal-file helper — helpers/signal_files.py

A tiny loader/replay helper so future signal-file strategies don't each re-implement parquet loading + entry/exit snapping (the logic currently duplicated in db_b4_strategy.py): load_signal_file(path) -> {symbol: [(entry, exit)]} and apply_entry_exit_signals(df, trades, max_snap_lag_days=7) -> df. Keep this optional — the core unblock is step 1.


Tests Needed

New file tests/test_symbol_injection.py (model after tests/test_registry.py / tests/test_example_strategies.py):

  1. test_symbol_passed_in_kwargs — register a probe strategy that records kwargs.get("symbol"); run the dispatch for a 2-symbol portfolio_data; assert each call received the correct ticker.
  2. test_symbol_stamped_on_attrs — probe strategy asserts df.attrs.get("symbol") == <expected> for each symbol (back-compat path).
  3. test_attrs_symbol_survives_copy — confirm the stamped value is present on the frame the strategy actually receives (guards against a future refactor that drops .attrs).
  4. test_no_regression_existing_strategies — an existing param-based strategy (e.g. SMA Crossover) still produces identical signals with the extra symbol kwarg present (unknown-kwarg tolerance).
  5. test_signal_file_strategy_fires — end-to-end: a DB_B4-style probe reads kwargs["symbol"], looks it up in a tiny in-memory signal map, and emits Signal = 1/-1 on the right bars; assert non-zero signals for a mapped symbol and all-zero for an unmapped one (this is the exact failure mode being fixed).
  6. test_symbol_isolated_across_symbols — no leakage: symbol A's stamped/kwarg value never bleeds into symbol B's call within one run_single_simulation.

All deterministic, no network, no real data provider — build portfolio_data from small hand-made DataFrames.


Acceptance Criteria

  • kwargs["symbol"] is present and correct in every strategy logic_func call.
  • df.attrs["symbol"] is set on the frame passed to the strategy.
  • db_b4_strategy.py and spy_tlt_seasonal_rotation.py produce non-zero, symbol-correct signals without edits when run in this engine.
  • All existing strategy/registry/simulation tests still pass (no regression).
  • New tests/test_symbol_injection.py passes.
  • Convention documented in helpers/registry.py and CLAUDE.md.

Notes / References

  • Source review: private-strategies PR feat: add config key validation with typo detection (task 12) #31 (Double Bottom B4 WF, PIT).
  • Affected plugins today: db_b4_strategy.py, spy_tlt_seasonal_rotation.py.
  • This is generalizable infrastructure, not a DB_B4 one-off — it unblocks every future signal-file and symbol-aware strategy.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions