Skip to content

Commit 01da4f0

Browse files
mahimn01mattsta
authored andcommitted
Populate NewsTick with its originating contract
tickNews was receiving the market-data reqId as `_reqId` (unused), so emitted news headlines had no way to signal which symbol they belonged to when more than one subscription was streaming news at once. Look up the reqId in `reqId2Ticker` (falling back to `_reqId2Contract`) and attach a `Contract.recreate(...)` snapshot to the `NewsTick`. Fixes #110 Fixes #214
1 parent 19129a4 commit 01da4f0

4 files changed

Lines changed: 91 additions & 3 deletions

File tree

ib_async/ib.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,8 @@ class IB:
209209
A profit- and loss entry for a single position is updated.
210210
211211
* ``tickNewsEvent`` (news: :class:`.NewsTick`):
212-
Emit a new news headline.
212+
Emit a new news headline with the associated contract when
213+
available.
213214
214215
* ``newsBulletinEvent`` (bulletin: :class:`.NewsBulletin`):
215216
Emit a new news bulletin.

ib_async/objects.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ class NewsTick:
452452
articleId: str
453453
headline: str
454454
extraData: str
455+
contract: Contract | None = None
455456

456457

457458
@dataclass(slots=True, frozen=True)

ib_async/wrapper.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,21 @@ def _endReq(self, key, result=None, success=True):
440440
else:
441441
future.set_exception(result)
442442

443+
def _snapshotContractForReqId(self, reqId: int) -> Contract | None:
444+
"""
445+
Return a stable contract snapshot for a market-data request id.
446+
447+
Prefer the live ticker mapping because that is the primary owner of
448+
market-data request identity. Fall back to the generic request
449+
contract map when no ticker is registered for the reqId.
450+
"""
451+
ticker = self.reqId2Ticker.get(reqId)
452+
if ticker:
453+
return Contract.recreate(ticker.contract)
454+
455+
contract = self._reqId2Contract.get(reqId)
456+
return Contract.recreate(contract) if contract else None
457+
443458
def startTicker(self, reqId: int, contract: Contract, tickType: int | str):
444459
"""
445460
Start a tick request that has the reqId associated with the contract.
@@ -1514,14 +1529,21 @@ def newsProviders(self, newsProviders: list[NewsProvider]):
15141529

15151530
def tickNews(
15161531
self,
1517-
_reqId: int,
1532+
reqId: int,
15181533
timeStamp: int,
15191534
providerCode: str,
15201535
articleId: str,
15211536
headline: str,
15221537
extraData: str,
15231538
):
1524-
news = NewsTick(timeStamp, providerCode, articleId, headline, extraData)
1539+
news = NewsTick(
1540+
timeStamp,
1541+
providerCode,
1542+
articleId,
1543+
headline,
1544+
extraData,
1545+
contract=self._snapshotContractForReqId(reqId),
1546+
)
15251547
self.newsTicks.append(news)
15261548
self.ib.tickNewsEvent.emit(news)
15271549

tests/test_news.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import ib_async as ibi
2+
from ib_async import IB, Stock
3+
from ib_async.ticker import Ticker
4+
5+
6+
def test_tickNews_populates_contract_from_active_ticker():
7+
"""When a ticker is registered for the incoming reqId (the normal
8+
streaming-news flow via reqMktData), the emitted NewsTick carries the
9+
originating contract as a typed snapshot."""
10+
ib = IB()
11+
contract = Stock("AAPL", "SMART", "USD")
12+
ib.wrapper.reqId2Ticker[1] = Ticker(contract=contract, defaults=ib.wrapper.defaults)
13+
14+
captured: list[ibi.NewsTick] = []
15+
ib.tickNewsEvent += captured.append
16+
17+
ib.wrapper.tickNews(
18+
1,
19+
1_700_000_000,
20+
"BRFG",
21+
"BRFG$abc",
22+
"Apple announces new chip",
23+
"",
24+
)
25+
26+
assert len(captured) == 1
27+
news = captured[0]
28+
assert news.headline == "Apple announces new chip"
29+
assert news.contract is not None
30+
assert news.contract.symbol == "AAPL"
31+
assert news.contract.secType == "STK"
32+
# recreate() returns a new instance so the snapshot is independent of
33+
# later mutations on the original contract.
34+
assert news.contract is not contract
35+
36+
37+
def test_tickNews_falls_back_to_reqId2Contract():
38+
"""When no ticker is registered but the reqId exists in the generic
39+
request-contract map, the fallback lookup still populates the contract."""
40+
ib = IB()
41+
contract = Stock("MSFT", "SMART", "USD")
42+
ib.wrapper._reqId2Contract[42] = contract
43+
44+
captured: list[ibi.NewsTick] = []
45+
ib.tickNewsEvent += captured.append
46+
47+
ib.wrapper.tickNews(42, 1_700_000_000, "BRFG", "BRFG$x", "Headline", "")
48+
49+
assert len(captured) == 1
50+
assert captured[0].contract is not None
51+
assert captured[0].contract.symbol == "MSFT"
52+
53+
54+
def test_tickNews_contract_none_when_reqId_unknown():
55+
"""When neither map knows the reqId, the NewsTick still emits with
56+
contract=None rather than raising."""
57+
ib = IB()
58+
captured: list[ibi.NewsTick] = []
59+
ib.tickNewsEvent += captured.append
60+
61+
ib.wrapper.tickNews(999, 1_700_000_000, "BRFG", "BRFG$x", "Headline", "")
62+
63+
assert len(captured) == 1
64+
assert captured[0].contract is None

0 commit comments

Comments
 (0)