Coverage for adrs/data/connector.py: 90%

40 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-29 05:07 +0800

1import json 

2import time 

3 

4from decimal import Decimal 

5from typing import Protocol, AsyncIterator, Callable, Awaitable 

6from nats_client import Msg 

7 

8from adrs.types import Topic, Message 

9 

10 

11class MetricStream(Protocol): 

12 async def subscribe( 

13 self, subject: str, callback: Callable[[Msg], Awaitable[None]] | None 

14 ): ... 

15 

16 async def publish(self, subject: str, payload: bytes, **kwargs) -> None: ... 

17 

18 

19class DatasourceStream(Protocol): 

20 async def connect(self, topics: list[Topic]) -> AsyncIterator[Message] | None: ... 

21 

22 

23DEFAULT_METRIC_NAMESPACE = "public_ts" 

24 

25 

26class MetricBuilder: 

27 """Publishes dashboard metrics under a namespace token (the JetStream root 

28 the dashboard consumes, e.g. `public_ts`).""" 

29 

30 def __init__( 

31 self, 

32 metric_stream: MetricStream, 

33 insert_prefix: str = DEFAULT_METRIC_NAMESPACE, 

34 ): 

35 self.metric_stream = metric_stream 

36 self.insert_prefix = insert_prefix 

37 

38 def _metric_subject(self, name: str) -> str: 

39 return f"{self.insert_prefix}.{name}" 

40 

41 @staticmethod 

42 def _dedup_headers(msg_id: str) -> dict[str, str]: 

43 """ 

44 Nats-Msg-Id for JetStream's server-side dedup: if two OMS replicas 

45 (e.g. old + new pod overlapping during a rolling deploy) publish the 

46 same logical record, the server collapses the second one instead of 

47 it landing twice downstream. 

48 """ 

49 return {"Nats-Msg-Id": msg_id} 

50 

51 async def create_alpha_trigger(self, alpha_id: str, topic: str | None = None): 

52 return await self.metric_stream.publish( 

53 self._metric_subject("alpha_trigger"), 

54 json.dumps( 

55 { 

56 "alpha_id": alpha_id, 

57 "topic": topic, 

58 "timestamp": time.time_ns(), 

59 } 

60 ).encode(), 

61 use_jetstream=True, 

62 ) 

63 

64 async def create_alpha_signal( 

65 self, 

66 alpha_id: str, 

67 signal: float | Decimal | str, 

68 timestamp: int, 

69 ): 

70 """`timestamp` (unix ns) is the time of the bar the signal was computed 

71 on, so the dashboard series lines up with the signal's own candle rather 

72 than the wall clock at insert time. Required — the caller always knows 

73 which bar it emitted, and defaulting to now() silently misdates the 

74 series whenever the emit lags the close.""" 

75 return await self.metric_stream.publish( 

76 self._metric_subject("alpha_signal"), 

77 json.dumps( 

78 { 

79 "alpha_id": alpha_id, 

80 "signal": str(signal), 

81 "timestamp": timestamp, 

82 } 

83 ).encode(), 

84 use_jetstream=True, 

85 ) 

86 

87 async def create_portfolio_signal( 

88 self, portfolio_id: str, signals: dict[str, Decimal] 

89 ): 

90 for asset, signal in signals.items(): 

91 await self.metric_stream.publish( 

92 self._metric_subject("portfolio_signal"), 

93 json.dumps( 

94 { 

95 "portfolio_id": portfolio_id, 

96 "asset": asset, 

97 "signal": str(signal), 

98 "timestamp": time.time_ns(), 

99 } 

100 ).encode(), 

101 use_jetstream=True, 

102 ) 

103 

104 async def create_alpha_alert( 

105 self, 

106 alpha_id: str, 

107 title: str, 

108 description: str = "", 

109 priority: int = 3, 

110 ): 

111 return await self.metric_stream.publish( 

112 self._metric_subject("alpha_alert"), 

113 json.dumps( 

114 { 

115 "alpha_id": alpha_id, 

116 "title": title, 

117 "description": description, 

118 "priority": priority, 

119 "timestamp": time.time_ns(), 

120 } 

121 ).encode(), 

122 use_jetstream=True, 

123 ) 

124 

125 async def create_portfolio_alert( 

126 self, 

127 portfolio_id: str, 

128 title: str, 

129 description: str = "", 

130 priority: int = 3, 

131 ): 

132 return await self.metric_stream.publish( 

133 self._metric_subject("portfolio_alert"), 

134 json.dumps( 

135 { 

136 "portfolio_id": portfolio_id, 

137 "title": title, 

138 "description": description, 

139 "priority": priority, 

140 "timestamp": time.time_ns(), 

141 } 

142 ).encode(), 

143 use_jetstream=True, 

144 ) 

145 

146 async def create_trade( 

147 self, 

148 oms_id: str, 

149 package_id: str, 

150 client_order_id: str, 

151 asset: str, 

152 symbol: str, 

153 exchange: str, 

154 executed_quantity: str, 

155 executed_price: str, 

156 executed_time: int, 

157 start_quantity: str, 

158 start_price: str, 

159 start_time: int, 

160 ): 

161 return await self.metric_stream.publish( 

162 self._metric_subject("trade"), 

163 json.dumps( 

164 { 

165 "oms_id": oms_id, 

166 "package_id": package_id, 

167 "client_order_id": client_order_id, 

168 "asset": asset, 

169 "symbol": symbol, 

170 "exchange": exchange, 

171 "executed_quantity": executed_quantity, 

172 "executed_price": executed_price, 

173 "executed_time": executed_time, 

174 "start_quantity": start_quantity, 

175 "start_price": start_price, 

176 "start_time": start_time, 

177 } 

178 ).encode(), 

179 use_jetstream=True, 

180 # No time-bucketing: a trade for one client_order_id is a 

181 # one-time event, so the key never needs to expire. 

182 headers=self._dedup_headers(f"trade:{oms_id}:{client_order_id}"), 

183 ) 

184 

185 async def create_position( 

186 self, 

187 oms_id: str, 

188 asset: str, 

189 symbol: str, 

190 exchange: str, 

191 quantity: str, 

192 price: str, 

193 updated_time: int, 

194 ): 

195 # Wall-clock minute bucket, not `updated_time`: the exchange only 

196 # bumps updated_time when the position actually changes, so keying 

197 # on it would collapse every legitimate heartbeat tick for a 

198 # position that's simply flat - breaking staleness monitoring 

199 # downstream. Bucketing on the publish minute only collapses 

200 # genuine same-tick duplicates (e.g. two replicas overlapping during 

201 # a rolling deploy) and still lets each new minute's tick through. 

202 minute_bucket = int(time.time() // 60) 

203 return await self.metric_stream.publish( 

204 self._metric_subject("position"), 

205 json.dumps( 

206 { 

207 "oms_id": oms_id, 

208 "asset": asset, 

209 "symbol": symbol, 

210 "exchange": exchange, 

211 "quantity": quantity, 

212 "price": price, 

213 "updated_time": updated_time, 

214 "timestamp": time.time_ns(), 

215 } 

216 ).encode(), 

217 use_jetstream=True, 

218 headers=self._dedup_headers(f"position:{oms_id}:{symbol}:{minute_bucket}"), 

219 ) 

220 

221 async def create_equity( 

222 self, 

223 oms_id: str, 

224 equity: str, 

225 ): 

226 # See create_position for why this buckets on the publish minute 

227 # rather than any field in the payload. 

228 minute_bucket = int(time.time() // 60) 

229 return ( 

230 await self.metric_stream.publish( 

231 self._metric_subject("equity"), 

232 json.dumps( 

233 { 

234 "oms_id": oms_id, 

235 "equity": equity, 

236 "timestamp": time.time_ns(), 

237 } 

238 ).encode(), 

239 use_jetstream=True, 

240 headers=self._dedup_headers(f"equity:{oms_id}:{minute_bucket}"), 

241 ), 

242 )