Skip to content

Commit c505a5b

Browse files
committed
Use snapshot prices for IB dry runs
1 parent fb0a987 commit c505a5b

4 files changed

Lines changed: 162 additions & 3 deletions

File tree

application/execution_service.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,26 @@
1313
import pandas as pd
1414

1515

16-
def get_market_prices(ib, symbols, *, fetch_quote_snapshots):
16+
def get_market_prices(
17+
ib,
18+
symbols,
19+
*,
20+
fetch_quote_snapshots,
21+
dry_run_only: bool = False,
22+
snapshot_price_fallbacks: dict[str, float] | None = None,
23+
):
1724
"""Fetch market prices for multiple symbols in one pass."""
1825
quotes = fetch_quote_snapshots(ib, symbols)
19-
return {symbol: quote.last_price for symbol, quote in quotes.items()}
26+
prices = {symbol: quote.last_price for symbol, quote in quotes.items()}
27+
if dry_run_only and snapshot_price_fallbacks:
28+
for symbol in symbols:
29+
normalized = str(symbol).strip().upper()
30+
if normalized in prices:
31+
continue
32+
fallback_price = snapshot_price_fallbacks.get(normalized)
33+
if fallback_price and float(fallback_price) > 0:
34+
prices[normalized] = float(fallback_price)
35+
return prices
2036

2137

2238
def check_order_submitted(report, *, translator):
@@ -364,7 +380,18 @@ def execute_rebalance(
364380
if strategy_symbols:
365381
all_symbols = all_symbols & set(strategy_symbols)
366382

367-
prices = get_market_prices(ib, all_symbols, fetch_quote_snapshots=fetch_quote_snapshots)
383+
snapshot_price_fallbacks = {
384+
str(symbol).strip().upper(): float(price)
385+
for symbol, price in dict(signal_metadata.get("dry_run_price_fallbacks") or {}).items()
386+
if price is not None
387+
}
388+
prices = get_market_prices(
389+
ib,
390+
all_symbols,
391+
fetch_quote_snapshots=fetch_quote_snapshots,
392+
dry_run_only=dry_run_only,
393+
snapshot_price_fallbacks=snapshot_price_fallbacks,
394+
)
368395

369396
current_mv = {}
370397
for symbol in all_symbols:

strategy_runtime.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ def _evaluate_feature_snapshot_strategy(
247247
},
248248
)
249249
return StrategyEvaluationResult(decision=decision, metadata=metadata)
250+
snapshot_close_map = self._build_snapshot_close_map(
251+
feature_snapshot,
252+
managed_symbols=managed_symbols,
253+
)
250254
metadata = {
251255
"strategy_profile": self.profile,
252256
"feature_snapshot_path": self.runtime_settings.feature_snapshot_path,
@@ -256,11 +260,42 @@ def _evaluate_feature_snapshot_strategy(
256260
"dry_run_only": self.runtime_settings.dry_run_only,
257261
"trade_date": run_as_of.date().isoformat(),
258262
"managed_symbols": managed_symbols,
263+
"dry_run_price_fallbacks": snapshot_close_map,
259264
"status_icon": self.status_icon,
260265
**guard_metadata,
261266
}
262267
return StrategyEvaluationResult(decision=decision, metadata=metadata)
263268

269+
def _build_snapshot_close_map(
270+
self,
271+
feature_snapshot,
272+
*,
273+
managed_symbols: tuple[str, ...],
274+
) -> dict[str, float]:
275+
if not managed_symbols:
276+
return {}
277+
try:
278+
frame = pd.DataFrame(feature_snapshot)
279+
except Exception:
280+
return {}
281+
if frame.empty or "symbol" not in frame.columns or "close" not in frame.columns:
282+
return {}
283+
frame = frame.copy()
284+
frame["symbol"] = frame["symbol"].astype(str).str.strip().str.upper()
285+
frame = frame[frame["symbol"].isin({str(symbol).strip().upper() for symbol in managed_symbols})]
286+
if frame.empty:
287+
return {}
288+
close_series = pd.to_numeric(frame["close"], errors="coerce")
289+
frame = frame.assign(close_numeric=close_series)
290+
frame = frame[frame["close_numeric"].notna() & frame["close_numeric"].gt(0)]
291+
if frame.empty:
292+
return {}
293+
deduped = frame.drop_duplicates(subset=["symbol"], keep="last")
294+
return {
295+
str(row["symbol"]): float(row["close_numeric"])
296+
for _, row in deduped.iterrows()
297+
}
298+
264299
def _extract_managed_symbols(
265300
self,
266301
feature_snapshot,

tests/test_execution_service.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,45 @@ def accountValues(self):
364364
assert summary["no_op_reason"] == "insufficient_buying_power:VOO"
365365
assert summary["skipped_reasons"] == ["insufficient_buying_power:VOO"]
366366
assert "failed insufficient_buying_power:VOO" in trade_logs[-1]
367+
368+
369+
def test_execute_rebalance_uses_snapshot_prices_for_dry_run_when_quotes_missing(tmp_path):
370+
class FakeIB:
371+
def openTrades(self):
372+
return []
373+
374+
def fills(self):
375+
return []
376+
377+
def accountValues(self):
378+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="5000")]
379+
380+
trade_logs, summary = execute_rebalance(
381+
FakeIB(),
382+
{"VOO": 0.6, "BOXX": 0.4},
383+
{},
384+
{"equity": 1000.0, "buying_power": 1000.0},
385+
fetch_quote_snapshots=lambda *_args, **_kwargs: {},
386+
submit_order_intent=lambda *_args, **_kwargs: None,
387+
order_intent_cls=OrderIntent,
388+
translator=translate,
389+
strategy_symbols=["VOO", "BOXX"],
390+
strategy_profile="tech_pullback_cash_buffer",
391+
signal_metadata={
392+
"trade_date": "2026-04-01",
393+
"snapshot_as_of": "2026-03-31",
394+
"dry_run_price_fallbacks": {"VOO": 100.0, "BOXX": 100.0},
395+
},
396+
dry_run_only=True,
397+
cash_reserve_ratio=0.0,
398+
rebalance_threshold_ratio=0.02,
399+
limit_buy_premium=1.005,
400+
sell_settle_delay_sec=0,
401+
execution_lock_dir=tmp_path,
402+
return_summary=True,
403+
)
404+
405+
assert summary["execution_status"] == "executed"
406+
assert len(summary["orders_submitted"]) == 2
407+
assert any(log.startswith("DRY_RUN buy VOO") for log in trade_logs)
408+
assert any(log.startswith("DRY_RUN buy BOXX") for log in trade_logs)

tests/test_snapshot_strategy_runtime.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,58 @@ def test_global_etf_rotation_keeps_default_cash_reserve(strategy_module_factory)
253253
)
254254

255255
assert module.CASH_RESERVE_RATIO == pytest.approx(0.03)
256+
257+
258+
def test_compute_signals_exposes_dry_run_price_fallbacks(strategy_module_factory, tmp_path):
259+
pytest.importorskip("pandas")
260+
261+
snapshot_path = tmp_path / "snapshot.csv"
262+
config_path = tmp_path / "tech_pullback_cash_buffer.json"
263+
snapshot_path.write_text(
264+
"\n".join(
265+
[
266+
"as_of,symbol,sector,close,adv20_usd,history_days,mom_6_1,mom_12_1,sma20_gap,sma50_gap,sma200_gap,ma50_over_ma200,vol_63,maxdd_126,breakout_252,dist_63_high,dist_126_high,rebound_20,base_eligible",
267+
"2026-03-31,QQQ,benchmark,500,1000000000,400,0.20,0.30,0.03,0.05,0.08,0.04,0.22,-0.12,-0.01,-0.03,-0.05,0.04,false",
268+
"2026-03-31,BOXX,defense,101,20000000,400,0.02,0.04,0.00,0.00,0.01,0.00,0.03,-0.01,0.00,-0.01,-0.01,0.00,false",
269+
"2026-03-31,AAPL,Information Technology,200,150000000,400,0.20,0.35,0.03,0.05,0.10,0.05,0.18,-0.08,-0.01,-0.03,-0.05,0.05,true",
270+
"2026-03-31,MSFT,Information Technology,350,150000000,400,0.18,0.33,0.03,0.05,0.09,0.04,0.17,-0.09,-0.02,-0.04,-0.06,0.04,true",
271+
]
272+
),
273+
encoding="utf-8",
274+
)
275+
config_path.write_text(
276+
json.dumps(
277+
{
278+
"name": "tech_pullback_cash_buffer",
279+
"family": "tech_heavy_pullback",
280+
"branch_role": "cash-buffered parallel branch",
281+
"benchmark_symbol": "QQQ",
282+
"holdings_count": 2,
283+
"single_name_cap": 0.5,
284+
"sector_cap": 1.0,
285+
"hold_bonus": 0.0,
286+
"min_adv20_usd": 1.0,
287+
"normalization": "universe_cross_sectional",
288+
"score_template": "balanced_pullback",
289+
"sector_whitelist": ["Information Technology"],
290+
"breadth_thresholds": {"soft": 0.55, "hard": 0.35},
291+
"exposures": {"risk_on": 1.0, "soft_defense": 1.0, "hard_defense": 0.0},
292+
"execution_cash_reserve_ratio": 0.0,
293+
"residual_proxy": "simple_excess_return_vs_QQQ",
294+
}
295+
),
296+
encoding="utf-8",
297+
)
298+
_write_cash_buffer_manifest(snapshot_path, config_path, snapshot_as_of="2026-03-31")
299+
300+
module = strategy_module_factory(
301+
STRATEGY_PROFILE="tech_pullback_cash_buffer",
302+
IBKR_FEATURE_SNAPSHOT_PATH=str(snapshot_path),
303+
IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=str(Path(f"{snapshot_path}.manifest.json")),
304+
IBKR_STRATEGY_CONFIG_PATH=str(config_path),
305+
IBKR_RUN_AS_OF_DATE="2026-04-01",
306+
)
307+
result = module.compute_signals(None, set())
308+
309+
assert result[4]["dry_run_price_fallbacks"]["BOXX"] == pytest.approx(101.0)
310+
assert result[4]["dry_run_price_fallbacks"]["AAPL"] == pytest.approx(200.0)

0 commit comments

Comments
 (0)