Skip to content

Commit 818a4df

Browse files
committed
Harden IB quote snapshot fallbacks
1 parent 3f29133 commit 818a4df

4 files changed

Lines changed: 97 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quant-platform-kit"
7-
version = "0.7.3"
7+
version = "0.7.4"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/quant_platform_kit/__init__.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""QuantPlatformKit public package surface."""
22

3-
__version__ = "0.7.3"
3+
__version__ = "0.7.4"
44

55
from .common.models import (
66
ExecutionReport,
@@ -21,26 +21,40 @@
2121
StrategyEntrypoint,
2222
StrategyManifest,
2323
StrategyRuntimeAdapter,
24+
ValueTargetExecutionAnnotations,
25+
ValueTargetExecutionPlan,
26+
ValueTargetPortfolioPlan,
27+
build_value_target_execution_annotations,
28+
build_value_target_portfolio_plan,
29+
build_strategy_context_from_available_inputs,
30+
build_value_target_execution_plan,
2431
validate_strategy_decision,
2532
validate_strategy_manifest,
2633
validate_strategy_runtime_adapter,
2734
)
2835
from .common.strategies import (
2936
CRYPTO_DOMAIN,
37+
PlatformCapabilityMatrix,
3038
PlatformStrategyPolicy,
39+
StrategyArtifactPaths,
3140
StrategyCatalog,
3241
StrategyDefinition,
3342
StrategyEntrypointDefinition,
3443
StrategyMetadata,
3544
US_EQUITY_DOMAIN,
3645
build_platform_profile_matrix,
46+
build_platform_profile_status_matrix,
3747
build_profile_aliases,
3848
build_strategy_catalog,
49+
derive_eligible_profiles_for_platform,
50+
derive_enabled_profiles_for_platform,
51+
derive_strategy_artifact_paths,
3952
build_strategy_index_rows,
4053
build_strategy_manifest,
4154
get_catalog_compatible_platforms,
4255
get_catalog_strategy_definition,
4356
get_catalog_strategy_metadata,
57+
get_catalog_target_mode,
4458
get_enabled_profiles_for_platform,
4559
load_strategy_entrypoint,
4660
normalize_profile_name,
@@ -53,6 +67,7 @@
5367
"BudgetIntent",
5468
"CRYPTO_DOMAIN",
5569
"ExecutionReport",
70+
"PlatformCapabilityMatrix",
5671
"PlatformStrategyPolicy",
5772
"OrderIntent",
5873
"PortfolioSnapshot",
@@ -61,6 +76,7 @@
6176
"PricePoint",
6277
"PriceSeries",
6378
"QuoteSnapshot",
79+
"StrategyArtifactPaths",
6480
"StrategyCatalog",
6581
"StrategyContext",
6682
"StrategyContractDecision",
@@ -72,15 +88,27 @@
7288
"StrategyManifest",
7389
"StrategyMetadata",
7490
"StrategyRuntimeAdapter",
91+
"ValueTargetExecutionAnnotations",
92+
"ValueTargetExecutionPlan",
93+
"ValueTargetPortfolioPlan",
94+
"build_value_target_execution_annotations",
7595
"US_EQUITY_DOMAIN",
96+
"build_value_target_portfolio_plan",
97+
"build_strategy_context_from_available_inputs",
98+
"build_value_target_execution_plan",
7699
"build_platform_profile_matrix",
100+
"build_platform_profile_status_matrix",
77101
"build_profile_aliases",
78102
"build_strategy_catalog",
103+
"derive_eligible_profiles_for_platform",
104+
"derive_enabled_profiles_for_platform",
105+
"derive_strategy_artifact_paths",
79106
"build_strategy_index_rows",
80107
"build_strategy_manifest",
81108
"get_catalog_compatible_platforms",
82109
"get_catalog_strategy_definition",
83110
"get_catalog_strategy_metadata",
111+
"get_catalog_target_mode",
84112
"get_enabled_profiles_for_platform",
85113
"load_strategy_entrypoint",
86114
"normalize_profile_name",

src/quant_platform_kit/ibkr/market_data.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,33 @@ def fetch_historical_price_series(
6565
return PriceSeries(symbol=symbol, currency=currency, points=points)
6666

6767

68-
def _extract_market_price(ticker: Any) -> float | None:
69-
price = ticker.marketPrice()
70-
if price is None:
68+
def _coerce_positive_price(value: Any) -> float | None:
69+
if value is None:
7170
return None
72-
if isinstance(price, float) and isnan(price):
73-
price = getattr(ticker, "close", None)
74-
if price is None:
71+
if isinstance(value, float) and isnan(value):
7572
return None
76-
if isinstance(price, float) and isnan(price):
73+
try:
74+
numeric = float(value)
75+
except (TypeError, ValueError):
7776
return None
78-
return float(price) if price > 0 else None
77+
return numeric if numeric > 0 else None
78+
79+
80+
def _extract_market_price(ticker: Any) -> float | None:
81+
for candidate in (
82+
ticker.marketPrice(),
83+
getattr(ticker, "last", None),
84+
getattr(ticker, "close", None),
85+
):
86+
price = _coerce_positive_price(candidate)
87+
if price is not None:
88+
return price
89+
90+
bid = _coerce_positive_price(getattr(ticker, "bid", None))
91+
ask = _coerce_positive_price(getattr(ticker, "ask", None))
92+
if bid is not None and ask is not None:
93+
return float((bid + ask) / 2.0)
94+
return bid or ask
7995

8096

8197
def fetch_quote_snapshots(

tests/test_ibkr_market_data.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,20 @@ class FakeBar:
2121

2222

2323
class FakeTicker:
24-
def __init__(self, market_price: float, close: float, bid: float | None = None, ask: float | None = None):
24+
def __init__(
25+
self,
26+
market_price: float,
27+
close: float,
28+
bid: float | None = None,
29+
ask: float | None = None,
30+
*,
31+
last: float | None = None,
32+
):
2533
self._market_price = market_price
2634
self.close = close
2735
self.bid = bid
2836
self.ask = ask
37+
self.last = last
2938

3039
def marketPrice(self) -> float:
3140
return self._market_price
@@ -83,5 +92,38 @@ def test_fetch_quote_snapshots_returns_last_price(self) -> None:
8392
self.assertEqual(len(ib.cancelled), 1)
8493

8594

95+
def test_fetch_quote_snapshots_falls_back_to_close_when_market_price_is_negative(self) -> None:
96+
class NegativePriceIB(FakeIB):
97+
def reqMktData(self, contract, *_args):
98+
self.last_market_data_contract = contract
99+
return FakeTicker(-1.0, close=101.8, bid=None, ask=None)
100+
101+
ib = NegativePriceIB()
102+
snapshots = fetch_quote_snapshots(
103+
ib,
104+
{"SPY"},
105+
wait_seconds=0,
106+
stock_factory=FakeContract,
107+
)
108+
109+
self.assertEqual(snapshots["SPY"].last_price, 101.8)
110+
111+
def test_fetch_quote_snapshots_falls_back_to_bid_ask_mid_when_last_and_close_missing(self) -> None:
112+
class BidAskOnlyIB(FakeIB):
113+
def reqMktData(self, contract, *_args):
114+
self.last_market_data_contract = contract
115+
return FakeTicker(-1.0, close=float("nan"), bid=102.4, ask=102.6)
116+
117+
ib = BidAskOnlyIB()
118+
snapshots = fetch_quote_snapshots(
119+
ib,
120+
{"SPY"},
121+
wait_seconds=0,
122+
stock_factory=FakeContract,
123+
)
124+
125+
self.assertEqual(snapshots["SPY"].last_price, 102.5)
126+
127+
86128
if __name__ == "__main__":
87129
unittest.main()

0 commit comments

Comments
 (0)