You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
custom_strategies/private/spy_tlt_seasonal_rotation.py:27 → symbol = df.attrs.get("symbol", "").upper(); the if symbol == "TLT": branch never fires, so TLT is silently treated like SPY.
Scope
In scope (this ticket):
Inject the current symbol into every strategy call via kwargs["symbol"] (the documented, preferred API going forward).
Also stamp df.attrs["symbol"] on the frame handed to the strategy, so existing attrs-based plugins (DB_B4, spy_tlt) work without edits.
Document the convention in helpers/registry.py and CLAUDE.md.
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()::
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.
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):
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.
test_symbol_stamped_on_attrs — probe strategy asserts df.attrs.get("symbol") == <expected> for each symbol (back-compat path).
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).
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).
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).
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.
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 readdf.attrs["symbol"], which the engine never sets. Dropped intomainas-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.
User Story
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:symbolis right there as the loop variable but is never passed tologic_func— not via**kwargs, not viadf.attrs..attrswrite anywhere inmain.py,helpers/, orservices/.custom_strategies/private/promotions/DB_B4/db_b4_strategy.py:91→sym = str(df.attrs.get("symbol", ""))→_SIGNALS.get("")→None→ all-zeroSignal.custom_strategies/private/spy_tlt_seasonal_rotation.py:27→symbol = df.attrs.get("symbol", "").upper(); theif symbol == "TLT":branch never fires, so TLT is silently treated like SPY.Scope
In scope (this ticket):
kwargs["symbol"](the documented, preferred API going forward).df.attrs["symbol"]on the frame handed to the strategy, so existingattrs-based plugins (DB_B4, spy_tlt) work without edits.helpers/registry.pyandCLAUDE.md.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.research.structural_followthroughmodules 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.pyIn
run_single_simulation, insidefor symbol, df in portfolio_data.items()::Notes:
(df, **kwargs)and ignores unknown keys, so addingsymboltokwargsis backward compatible — verified against the registry contract inhelpers/registry.py.portfolio_data[symbol]) keeps the global data immutable and is process-safe under the existing multiprocessing model.logic_funcis invoked (audit for a second call site; currently only this loop).2. Document the convention
helpers/registry.pymodule docstring +register_strategydocstring: add a short "Symbol identity" note — "the engine always injects the current ticker askwargs['symbol'](and mirrors it ondf.attrs['symbol']); readkwargs.get('symbol')for symbol-aware or signal-file strategies."CLAUDE.md→ "Adding a Strategy (Plugin System)": documentkwargs["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.pyA 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)]}andapply_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 aftertests/test_registry.py/tests/test_example_strategies.py):test_symbol_passed_in_kwargs— register a probe strategy that recordskwargs.get("symbol"); run the dispatch for a 2-symbolportfolio_data; assert each call received the correct ticker.test_symbol_stamped_on_attrs— probe strategy assertsdf.attrs.get("symbol") == <expected>for each symbol (back-compat path).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).test_no_regression_existing_strategies— an existing param-based strategy (e.g. SMA Crossover) still produces identical signals with the extrasymbolkwarg present (unknown-kwarg tolerance).test_signal_file_strategy_fires— end-to-end: a DB_B4-style probe readskwargs["symbol"], looks it up in a tiny in-memory signal map, and emitsSignal = 1/-1on 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).test_symbol_isolated_across_symbols— no leakage: symbol A's stamped/kwarg value never bleeds into symbol B's call within onerun_single_simulation.All deterministic, no network, no real data provider — build
portfolio_datafrom small hand-made DataFrames.Acceptance Criteria
kwargs["symbol"]is present and correct in every strategylogic_funccall.df.attrs["symbol"]is set on the frame passed to the strategy.db_b4_strategy.pyandspy_tlt_seasonal_rotation.pyproduce non-zero, symbol-correct signals without edits when run in this engine.tests/test_symbol_injection.pypasses.helpers/registry.pyandCLAUDE.md.Notes / References
db_b4_strategy.py,spy_tlt_seasonal_rotation.py.