Dmitry Beresnev commited on
Commit
55ec4c5
Β·
1 Parent(s): a8327d8

add insider trades info cmd

Browse files
.env.example CHANGED
@@ -8,4 +8,5 @@ GOOGLE_APPS_SCRIPT_URL=
8
  WEBHOOK_SECRET=
9
  SPACE_URL=
10
  HF_TOKEN=
11
- HF_DATASET_REPO=
 
 
8
  WEBHOOK_SECRET=
9
  SPACE_URL=
10
  HF_TOKEN=
11
+ HF_DATASET_REPO=
12
+ FMP_API_TOKEN=
src/api/insiders/__init__.py ADDED
File without changes
src/api/insiders/insider_trade.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from typing import Optional
4
+ import hashlib
5
+
6
+
7
+ @dataclass
8
+ class InsiderTrade:
9
+ """Data class to standardize insider trading information"""
10
+ symbol: str
11
+ company_name: str
12
+ insider_name: str
13
+ position: str
14
+ transaction_date: str
15
+ transaction_type: str
16
+ shares: int
17
+ price: float
18
+ value: float
19
+ form_type: str
20
+ source: str
21
+ filing_date: str = ""
22
+ ownership_type: str = "" # Direct/Indirect
23
+
24
+ # IMPROVEMENT: Add hash field directly to dataclass for clarity
25
+ hash: str = field(init=False, repr=False)
26
+
27
+ def __post_init__(self):
28
+ """Generate unique hash for deduplication"""
29
+ # Create hash based on key identifying fields
30
+ hash_string = f"{self.symbol}_{self.insider_name}_{self.transaction_date}_{self.shares}_{self.price}_{self.transaction_type}"
31
+ self.hash = hashlib.md5(hash_string.encode()).hexdigest()
32
+
33
+ @property
34
+ def transaction_date_dt(self) -> Optional[datetime]:
35
+ """Convert transaction date to datetime object"""
36
+ if not self.transaction_date or not isinstance(self.transaction_date, str):
37
+ return None
38
+ try:
39
+ # Handle various date formats
40
+ for fmt in ['%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y-%m-%d %H:%M:%S']:
41
+ try:
42
+ return datetime.strptime(self.transaction_date, fmt)
43
+ except ValueError:
44
+ continue
45
+ return None
46
+ # IMPROVEMENT: Catch specific exceptions instead of a generic one.
47
+ except (ValueError, TypeError):
48
+ return None
src/api/insiders/insider_trading_aggregator.py ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from collections import Counter
3
+ from datetime import datetime, timedelta
4
+ from typing import List, Optional, Dict
5
+ import aiohttp
6
+
7
+ import pandas as pd
8
+
9
+ from src.api.insiders.insider_trade import InsiderTrade
10
+ from src.api.insiders.trading_report import TradingReport
11
+ from src.api.insiders.transaction_types import TransactionType
12
+ from src.telegram_bot.logger import main_logger as logger
13
+
14
+
15
+ class InsiderTradingAggregator:
16
+ """Async aggregator for insider trading data from multiple APIs"""
17
+
18
+ def __init__(self, session_timeout: int = 30):
19
+ # API configurations
20
+ self.apis = {
21
+ 'fmp': {
22
+ 'base_url': 'https://financialmodelingprep.com/api/v4',
23
+ 'api_key': None,
24
+ 'rate_limit': 250, # Daily limit
25
+ 'requests_per_minute': 10
26
+ },
27
+ 'sec_api': {
28
+ 'base_url': 'https://api.sec-api.io',
29
+ 'api_key': None,
30
+ 'rate_limit': 100,
31
+ 'requests_per_minute': 5
32
+ },
33
+ 'eod': {
34
+ 'base_url': 'https://eodhistoricaldata.com/api',
35
+ 'api_key': None,
36
+ 'rate_limit': 1000,
37
+ 'requests_per_minute': 20
38
+ },
39
+ 'tradefeeds': {
40
+ 'base_url': 'https://api.tradefeeds.com',
41
+ 'api_key': None,
42
+ 'rate_limit': 500,
43
+ 'requests_per_minute': 15
44
+ }
45
+ }
46
+
47
+ # Rate limiting tracking
48
+ self.request_counts = {api: 0 for api in self.apis.keys()}
49
+ self.last_reset = datetime.now()
50
+ self.session_timeout = session_timeout
51
+
52
+ # Semaphores for rate limiting per minute
53
+ self.semaphores = {
54
+ api: asyncio.Semaphore(config['requests_per_minute'])
55
+ for api, config in self.apis.items()
56
+ }
57
+
58
+ def set_api_key(self, api_name: str, api_key: str) -> None:
59
+ """Set API key for a specific service"""
60
+ if api_name in self.apis:
61
+ self.apis[api_name]['api_key'] = api_key
62
+ logger.info(f"API key set for {api_name}")
63
+ else:
64
+ logger.error(f"Unknown API: {api_name}")
65
+
66
+ def _check_rate_limit(self, api_name: str) -> bool:
67
+ """Check if we're within daily rate limits"""
68
+ if datetime.now() - self.last_reset > timedelta(days=1):
69
+ self.request_counts = {api: 0 for api in self.apis.keys()}
70
+ self.last_reset = datetime.now()
71
+
72
+ return self.request_counts[api_name] < self.apis[api_name]['rate_limit']
73
+
74
+ async def _make_request(self, session: aiohttp.ClientSession, api_name: str,
75
+ endpoint: str, params: Dict = None) -> Optional[Dict]:
76
+ """Make async rate-limited request to an API"""
77
+ if not self._check_rate_limit(api_name):
78
+ logger.warning(f"Daily rate limit reached for {api_name}")
79
+ return None
80
+
81
+ if not self.apis[api_name]['api_key']:
82
+ logger.warning(f"No API key set for {api_name}, skipping request.")
83
+ return None
84
+
85
+ # Rate limiting with semaphore
86
+ async with self.semaphores[api_name]:
87
+ try:
88
+ url = f"{self.apis[api_name]['base_url']}/{endpoint}"
89
+ if params is None:
90
+ params = {}
91
+
92
+ # Add API key to parameters
93
+ if api_name == 'fmp':
94
+ params['apikey'] = self.apis[api_name]['api_key']
95
+ elif api_name in ['sec_api', 'eod', 'tradefeeds']:
96
+ params['token'] = self.apis[api_name]['api_key']
97
+
98
+ async with session.get(url, params=params) as response:
99
+ response.raise_for_status()
100
+ self.request_counts[api_name] += 1
101
+ return await response.json()
102
+
103
+ except aiohttp.ClientError as e:
104
+ logger.error(f"Request failed for {api_name} ({url}): {e}")
105
+ return None
106
+
107
+ # BUG FIX: Removed the unnecessary sleep. The semaphore already handles rate limiting.
108
+ # This was a major performance bottleneck.
109
+ # await asyncio.sleep(60 / self.apis[api_name]['requests_per_minute'])
110
+
111
+ async def get_fmp_insider_trades(self, session: aiohttp.ClientSession,
112
+ symbol: str, limit: int = 100) -> List[InsiderTrade]:
113
+ """Get insider trades from Financial Modeling Prep API"""
114
+ trades = []
115
+ # IMPROVEMENT: Use f-string for endpoint construction
116
+ endpoint = f"insider-trading"
117
+ params = {'symbol': symbol, 'limit': limit}
118
+
119
+ data = await self._make_request(session, 'fmp', endpoint, params)
120
+ if not data:
121
+ return trades
122
+
123
+ for trade in data:
124
+ try:
125
+ disposition = trade.get('acquistionOrDisposition', '').upper()
126
+ trans_type = TransactionType.BUY.value if disposition == 'A' else TransactionType.SELL.value
127
+
128
+ insider_trade = InsiderTrade(
129
+ symbol=trade.get('symbol', ''),
130
+ company_name=trade.get('companyName', ''),
131
+ insider_name=trade.get('reportingName', ''),
132
+ position=trade.get('typeOfOwner', ''),
133
+ transaction_date=trade.get('transactionDate', ''),
134
+ transaction_type=trans_type,
135
+ shares=int(trade.get('securitiesTransacted', 0) or 0),
136
+ price=float(trade.get('price', 0) or 0),
137
+ value=float(trade.get('securitiesTransacted', 0) or 0) * float(trade.get('price', 0) or 0),
138
+ form_type=trade.get('formType', ''),
139
+ source='FMP',
140
+ filing_date=trade.get('filingDate', ''),
141
+ ownership_type=trade.get('directOrIndirectOwnership', '')
142
+ )
143
+ trades.append(insider_trade)
144
+ except (ValueError, TypeError) as e:
145
+ logger.warning(f"Error parsing FMP trade data for {symbol}: {e} -> {trade}")
146
+ continue
147
+
148
+ logger.info(f"Retrieved {len(trades)} trades from FMP for {symbol}")
149
+ return trades
150
+
151
+ async def get_sec_api_insider_trades(self, session: aiohttp.ClientSession,
152
+ symbol: str, limit: int = 100) -> List[InsiderTrade]:
153
+ """Get insider trades from SEC-API"""
154
+ trades = []
155
+ endpoint = "insider-transactions"
156
+ params = {'ticker': symbol, 'limit': limit}
157
+
158
+ data = await self._make_request(session, 'sec_api', endpoint, params)
159
+ if not data or 'transactions' not in data:
160
+ return trades
161
+
162
+ for trade in data['transactions']:
163
+ try:
164
+ insider_trade = InsiderTrade(
165
+ symbol=trade.get('ticker', ''),
166
+ company_name=trade.get('companyName', ''),
167
+ insider_name=trade.get('personName', ''),
168
+ position=trade.get('position', ''),
169
+ transaction_date=trade.get('transactionDate', ''),
170
+ transaction_type=trade.get('transactionType', ''),
171
+ shares=int(trade.get('sharesTraded', 0) or 0),
172
+ price=float(trade.get('pricePerShare', 0) or 0),
173
+ value=float(trade.get('transactionValue', 0) or 0),
174
+ form_type=trade.get('formType', ''),
175
+ source='SEC-API',
176
+ filing_date=trade.get('filingDate', '')
177
+ )
178
+ trades.append(insider_trade)
179
+ except (ValueError, TypeError) as e:
180
+ logger.warning(f"Error parsing SEC-API trade data for {symbol}: {e} -> {trade}")
181
+ continue
182
+
183
+ logger.info(f"Retrieved {len(trades)} trades from SEC-API for {symbol}")
184
+ return trades
185
+
186
+ async def get_eod_insider_trades(self, session: aiohttp.ClientSession,
187
+ symbol: str) -> List[InsiderTrade]:
188
+ """Get insider trades from EOD Historical Data"""
189
+ trades = []
190
+ # NOTE: This endpoint hardcodes the US exchange.
191
+ endpoint = f"insider-transactions/{symbol}.US"
192
+
193
+ data = await self._make_request(session, 'eod', endpoint)
194
+ if not data:
195
+ return trades
196
+
197
+ # EOD data can be a dictionary of lists, so we iterate through its values.
198
+ data_to_iterate = data.values() if isinstance(data, dict) else data
199
+
200
+ for trade in data_to_iterate:
201
+ try:
202
+ insider_trade = InsiderTrade(
203
+ symbol=symbol,
204
+ company_name='', # EOD does not provide company name in this endpoint
205
+ insider_name=trade.get('ownerName', ''),
206
+ position=trade.get('ownerPosition', ''),
207
+ transaction_date=trade.get('date', ''),
208
+ transaction_type=trade.get('transactionType', ''),
209
+ shares=int(trade.get('transactionAmount', 0) or 0),
210
+ price=float(trade.get('transactionPrice', 0) or 0),
211
+ value=float(trade.get('transactionAmount', 0) or 0) * float(trade.get('transactionPrice', 0) or 0),
212
+ form_type='Form 4',
213
+ source='EOD'
214
+ )
215
+ trades.append(insider_trade)
216
+ except (ValueError, TypeError) as e:
217
+ logger.warning(f"Error parsing EOD trade data for {symbol}: {e} -> {trade}")
218
+ continue
219
+
220
+ logger.info(f"Retrieved {len(trades)} trades from EOD for {symbol}")
221
+ return trades
222
+
223
+ async def get_tradefeeds_insider_trades(self, session: aiohttp.ClientSession,
224
+ symbol: str, limit: int = 100) -> List[InsiderTrade]:
225
+ """Get insider trades from Tradefeeds API"""
226
+ trades = []
227
+ endpoint = "insider_transactions"
228
+ params = {'symbol': symbol, 'limit': limit}
229
+
230
+ data = await self._make_request(session, 'tradefeeds', endpoint, params)
231
+ if not data or 'data' not in data:
232
+ return trades
233
+
234
+ for trade in data['data']:
235
+ try:
236
+ shares = int(trade.get('sharesTraded', 0) or 0)
237
+ price = float(trade.get('averagePrice', 0) or 0)
238
+ insider_trade = InsiderTrade(
239
+ symbol=trade.get('symbol', ''),
240
+ company_name=trade.get('companyName', ''),
241
+ insider_name=trade.get('insiderName', ''),
242
+ position=trade.get('relationship', ''),
243
+ transaction_date=trade.get('transactionDate', ''),
244
+ transaction_type=trade.get('transactionCode', ''),
245
+ shares=shares,
246
+ price=price,
247
+ value=shares * price,
248
+ form_type='Form 4',
249
+ source='Tradefeeds'
250
+ )
251
+ trades.append(insider_trade)
252
+ except (ValueError, TypeError) as e:
253
+ logger.warning(f"Error parsing Tradefeeds trade data for {symbol}: {e} -> {trade}")
254
+ continue
255
+
256
+ logger.info(f"Retrieved {len(trades)} trades from Tradefeeds for {symbol}")
257
+ return trades
258
+
259
+ async def get_factored_ai_insider_trades(self, session: aiohttp.ClientSession,
260
+ symbol: str) -> List[InsiderTrade]:
261
+ """Get insider trades from Factored.AI (S&P 500 companies only)"""
262
+ trades = []
263
+ try:
264
+ url = f"https://raw.githubusercontent.com/Factored-AI/insider-trading/main/data/{symbol}_insider_trades.json"
265
+
266
+ async with session.get(url) as response:
267
+ if response.status == 200:
268
+ data = await response.json()
269
+
270
+ for trade in data:
271
+ try:
272
+ shares = int(trade.get('shares', 0) or 0)
273
+ price = float(trade.get('price', 0) or 0)
274
+ insider_trade = InsiderTrade(
275
+ symbol=symbol,
276
+ company_name=trade.get('company_name', ''),
277
+ insider_name=trade.get('insider_name', ''),
278
+ position=trade.get('position', ''),
279
+ transaction_date=trade.get('transaction_date', ''),
280
+ transaction_type=trade.get('transaction_type', ''),
281
+ shares=shares,
282
+ price=price,
283
+ value=shares * price,
284
+ form_type='Form 4',
285
+ source='Factored.AI'
286
+ )
287
+ trades.append(insider_trade)
288
+ except (ValueError, TypeError) as e:
289
+ logger.warning(f"Error parsing Factored.AI trade data for {symbol}: {e} -> {trade}")
290
+ continue
291
+
292
+ logger.info(f"Retrieved {len(trades)} trades from Factored.AI for {symbol}")
293
+ else:
294
+ logger.info(f"No Factored.AI data found for {symbol} (status: {response.status})")
295
+
296
+ except aiohttp.ClientError as e:
297
+ logger.error(f"Failed to retrieve data from Factored.AI for {symbol}: {e}")
298
+
299
+ return trades
300
+
301
+ def deduplicate_trades(self, trades: List[InsiderTrade]) -> List[InsiderTrade]:
302
+ """Advanced deduplication based on multiple factors"""
303
+ if not trades:
304
+ return []
305
+
306
+ # First pass: remove exact hash duplicates
307
+ unique_trades_map = {trade.hash: trade for trade in trades}
308
+ unique_trades = list(unique_trades_map.values())
309
+
310
+ # Second pass: group similar trades (same person, date, similar values)
311
+ # BUG FIX: The original nested loop was inefficient and could miss duplicates.
312
+ # This approach of sorting and grouping is more robust.
313
+ unique_trades.sort(key=lambda t: (t.insider_name.lower(), t.transaction_date, t.value))
314
+
315
+ if not unique_trades:
316
+ return []
317
+
318
+ final_trades = [unique_trades[0]]
319
+ for i in range(1, len(unique_trades)):
320
+ prev_trade = final_trades[-1]
321
+ curr_trade = unique_trades[i]
322
+
323
+ # Check for similarity
324
+ if (prev_trade.insider_name.lower() == curr_trade.insider_name.lower() and
325
+ prev_trade.transaction_date == curr_trade.transaction_date and
326
+ abs(prev_trade.value - curr_trade.value) < 1000): # Within $1000 tolerance
327
+
328
+ # Keep the trade with more complete information or from a preferred source
329
+ if len(curr_trade.company_name) > len(prev_trade.company_name):
330
+ final_trades[-1] = curr_trade # Replace previous with current
331
+ else:
332
+ final_trades.append(curr_trade)
333
+
334
+ logger.info(f"Deduplication: {len(trades)} -> {len(final_trades)} trades")
335
+ return final_trades
336
+
337
+ def filter_trades_by_period(self, trades: List[InsiderTrade],
338
+ days: int = 7, end_date: Optional[datetime] = None) -> List[InsiderTrade]:
339
+ """Filter trades by time period (default: last 7 days)"""
340
+ if end_date is None:
341
+ end_date = datetime.now()
342
+
343
+ start_date = end_date - timedelta(days=days)
344
+
345
+ filtered_trades = []
346
+ for trade in trades:
347
+ trade_date = trade.transaction_date_dt
348
+ if trade_date and start_date <= trade_date <= end_date:
349
+ filtered_trades.append(trade)
350
+
351
+ logger.info(f"Filtered {len(filtered_trades)} trades from last {days} days")
352
+ return filtered_trades
353
+
354
+ async def get_all_insider_trades(self, symbol: str, limit_per_api: int = 100,
355
+ filter_days: int = 7) -> List[InsiderTrade]:
356
+ """Aggregate insider trades from all available APIs with async processing"""
357
+ timeout = aiohttp.ClientTimeout(total=self.session_timeout)
358
+
359
+ async with aiohttp.ClientSession(timeout=timeout) as session:
360
+ # Create tasks for all API calls
361
+ tasks = [
362
+ self.get_fmp_insider_trades(session, symbol, limit_per_api),
363
+ #self.get_sec_api_insider_trades(session, symbol, limit_per_api),
364
+ #self.get_eod_insider_trades(session, symbol),
365
+ #self.get_tradefeeds_insider_trades(session, symbol, limit_per_api),
366
+ #self.get_factored_ai_insider_trades(session, symbol)
367
+ ]
368
+
369
+ # Execute all tasks concurrently
370
+ results = await asyncio.gather(*tasks, return_exceptions=True)
371
+
372
+ # Combine results, filtering out exceptions
373
+ all_trades = []
374
+ api_names = ['FMP', 'SEC-API', 'EOD', 'Tradefeeds', 'Factored.AI']
375
+
376
+ for i, result in enumerate(results):
377
+ if isinstance(result, Exception):
378
+ logger.error(f"Error fetching from {api_names[i]} for {symbol}: {result}")
379
+ elif result:
380
+ all_trades.extend(result)
381
+
382
+ # Deduplicate trades
383
+ unique_trades = self.deduplicate_trades(all_trades)
384
+
385
+ # Filter by period
386
+ if filter_days > 0:
387
+ unique_trades = self.filter_trades_by_period(unique_trades, filter_days)
388
+
389
+ logger.info(f"Total unique trades found for {symbol}: {len(unique_trades)}")
390
+ return unique_trades
391
+
392
+ def generate_trading_report(self, trades: List[InsiderTrade],
393
+ period_days: int = 7) -> Optional[TradingReport]:
394
+ """Generate comprehensive trading report"""
395
+ if not trades:
396
+ return None
397
+
398
+ # Basic statistics
399
+ symbol = trades[0].symbol
400
+
401
+ # BUG FIX: Get the most common company name to avoid issues with inconsistent API data
402
+ company_name_counter = Counter(t.company_name for t in trades if t.company_name)
403
+ company_name = company_name_counter.most_common(1)[0][0] if company_name_counter else "N/A"
404
+
405
+ total_trades = len(trades)
406
+
407
+ # Separate buy/sell trades
408
+ buy_trades = [t for t in trades if 'buy' in t.transaction_type.lower() or t.transaction_type.lower() == 'a']
409
+ sell_trades = [t for t in trades if 'sell' in t.transaction_type.lower() or t.transaction_type.lower() == 'd']
410
+
411
+ total_value_bought = sum(abs(t.value) for t in buy_trades)
412
+ total_value_sold = sum(abs(t.value) for t in sell_trades)
413
+ net_value = total_value_bought - total_value_sold
414
+
415
+ # Top insiders by activity
416
+ insider_stats = {}
417
+ for trade in trades:
418
+ name = trade.insider_name
419
+ if name not in insider_stats:
420
+ insider_stats[name] = {'count': 0, 'value': 0}
421
+ insider_stats[name]['count'] += 1
422
+ insider_stats[name]['value'] += abs(trade.value)
423
+
424
+ top_insiders = sorted(
425
+ [(name, stats['count'], stats['value']) for name, stats in insider_stats.items()],
426
+ key=lambda x: x[2], reverse=True
427
+ )[:5]
428
+
429
+ # Period calculation
430
+ trade_dates = [t.transaction_date_dt for t in trades if t.transaction_date_dt]
431
+ period_start = min(trade_dates) if trade_dates else datetime.now() - timedelta(days=period_days)
432
+ period_end = max(trade_dates) if trade_dates else datetime.now()
433
+
434
+ return TradingReport(
435
+ symbol=symbol,
436
+ company_name=company_name,
437
+ total_trades=total_trades,
438
+ buy_trades=len(buy_trades),
439
+ sell_trades=len(sell_trades),
440
+ total_value_bought=total_value_bought,
441
+ total_value_sold=total_value_sold,
442
+ net_value=net_value,
443
+ top_insiders=top_insiders,
444
+ period_start=period_start,
445
+ period_end=period_end,
446
+ trades=trades
447
+ )
448
+
449
+ def format_telegram_message(self, report: TradingReport) -> str:
450
+ """Generate formatted Telegram message from trading report"""
451
+ if not report:
452
+ return "❌ No insider trading data found for the specified period."
453
+
454
+ trend_emoji = "πŸ“ˆ" if report.net_value > 0 else "πŸ“‰" if report.net_value < 0 else "➑️"
455
+
456
+ message = f"πŸ” **INSIDER TRADING REPORT** πŸ”\n\n"
457
+ message += f"🏒 **Company:** {report.company_name} (${report.symbol})\n"
458
+ message += f"πŸ“… **Period:** {report.period_start.strftime('%Y-%m-%d')} to {report.period_end.strftime('%Y-%m-%d')}\n\n"
459
+
460
+ message += "πŸ“Š **SUMMARY**\n"
461
+ message += f"β€’ Total Trades: {report.total_trades}\n"
462
+ message += f"β€’ Buy Trades: {report.buy_trades} 🟒\n"
463
+ message += f"β€’ Sell Trades: {report.sell_trades} πŸ”΄\n\n"
464
+
465
+ message += "πŸ’° **FINANCIAL IMPACT**\n"
466
+ message += f"β€’ Total Bought: ${report.total_value_bought:,.2f}\n"
467
+ message += f"β€’ Total Sold: ${report.total_value_sold:,.2f}\n"
468
+ message += f"β€’ Net Position: {trend_emoji} ${report.net_value:,.2f}\n\n"
469
+
470
+ message += "πŸ‘₯ **TOP INSIDERS BY ACTIVITY**\n"
471
+ message += "```\n"
472
+ message += "β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n"
473
+ message += "β”‚ Name β”‚ Trades β”‚ Total Value β”‚\n"
474
+ message += "β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€\n"
475
+
476
+ for name, count, value in report.top_insiders:
477
+ display_name = (name[:23] + '..') if len(name) > 25 else name
478
+ message += f"β”‚ {display_name:<24} β”‚ {count:<6} β”‚ ${value: >10,.0f} β”‚\n"
479
+
480
+ message += "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n```\n"
481
+
482
+ if report.trades:
483
+ message += "\nπŸ“‹ **RECENT SIGNIFICANT TRADES**\n"
484
+ top_trades = sorted(report.trades, key=lambda x: abs(x.value), reverse=True)[:5]
485
+
486
+ message += "```\n"
487
+ message += "β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n"
488
+ message += "β”‚ Insider Name β”‚ Type β”‚ Date β”‚ Shares β”‚ Total Value β”‚\n"
489
+ message += "β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€\n"
490
+
491
+ for trade in top_trades:
492
+ insider_name = (trade.insider_name[:16] + '..') if len(trade.insider_name) > 18 else trade.insider_name
493
+ trans_type = "BUY" if "buy" in trade.transaction_type.lower() or "a" == trade.transaction_type.lower() else "SELL"
494
+ type_display = f"{'🟒' if trans_type == 'BUY' else 'πŸ”΄'}{trans_type}"
495
+
496
+ date_str = trade.transaction_date_dt.strftime(
497
+ '%Y-%m-%d') if trade.transaction_date_dt else trade.transaction_date[:10]
498
+
499
+ message += f"β”‚ {insider_name:<17} β”‚ {type_display:<4} β”‚ {date_str:<8} β”‚ {trade.shares:>8,d} β”‚ ${abs(trade.value):>10,.0f} β”‚\n"
500
+
501
+ message += "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n```\n"
502
+
503
+ sources = sorted(list(set(t.source for t in report.trades)))
504
+ message += f"\nπŸ”— **Sources:** {', '.join(sources)}\n"
505
+ message += f"⏰ Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
506
+
507
+ return message
508
+
509
+ # BUG FIX: This method was combined with the one below it, causing a SyntaxError.
510
+ # It has been properly separated now.
511
+ def format_telegram_message_ultra_compact(self, report: TradingReport) -> str:
512
+ """Ultra compact format for high-frequency monitoring"""
513
+ if not report or not report.trades:
514
+ return f"▫️ ${report.symbol}: No activity"
515
+
516
+ trend = "πŸ“ˆ" if report.net_value > 0 else "πŸ“‰" if report.net_value < 0 else "➑️"
517
+
518
+ # Get biggest trade
519
+ biggest_trade = max(report.trades, key=lambda x: abs(x.value))
520
+
521
+ message = f"🚨 **${report.symbol}** | {trend} Net: ${report.net_value:,.0f} | {report.total_trades} trades"
522
+
523
+ trans_type = "BUY" if "buy" in biggest_trade.transaction_type.lower() else "SELL"
524
+ # Get last name for brevity
525
+ insider_short = biggest_trade.insider_name.split()[-1]
526
+ message += f"\n Lgst: {trans_type} ${abs(biggest_trade.value):,.0f} by {insider_short}"
527
+
528
+ return message
529
+
530
+ # BUG FIX: This method was missing its definition and was tangled with the above method.
531
+ def format_telegram_message_short(self, report: TradingReport) -> str:
532
+ """Generate short Telegram message for quick updates"""
533
+ if not report:
534
+ return f"❌ No insider trading activity found."
535
+
536
+ trend_emoji = "πŸ“ˆ" if report.net_value > 0 else "πŸ“‰" if report.net_value < 0 else "➑️"
537
+
538
+ message = f"🚨 **${report.symbol} Insider Alert** 🚨\n\n"
539
+ message += f"{trend_emoji} Net Value: **${report.net_value:,.0f}**\n"
540
+ message += f"πŸ“Š Total Trades: {report.total_trades} ({report.buy_trades}B / {report.sell_trades}S)\n\n"
541
+
542
+ if report.top_insiders:
543
+ top_insider_name = report.top_insiders[0][0]
544
+ top_insider_value = report.top_insiders[0][2]
545
+ # Truncate long names
546
+ display_name = (top_insider_name[:25] + '...') if len(top_insider_name) > 28 else top_insider_name
547
+ message += f"πŸ‘€ Top Insider: {display_name}\n"
548
+ message += f"πŸ’° Total Activity: ${top_insider_value:,.0f}"
549
+ else:
550
+ message += "πŸ‘€ No top insider data available."
551
+
552
+ return message
553
+
554
+ async def get_multiple_symbols_report(self, symbols: List[str],
555
+ filter_days: int = 7) -> Dict[str, TradingReport]:
556
+ """Get reports for multiple symbols concurrently"""
557
+ tasks = [self.get_all_insider_trades(symbol, filter_days=filter_days) for symbol in symbols]
558
+
559
+ results = await asyncio.gather(*tasks, return_exceptions=True)
560
+
561
+ reports = {}
562
+ for i, result in enumerate(results):
563
+ symbol = symbols[i]
564
+ if isinstance(result, Exception):
565
+ logger.error(f"Error processing {symbol}: {result}")
566
+ reports[symbol] = None
567
+ else:
568
+ reports[symbol] = self.generate_trading_report(result, filter_days)
569
+
570
+ return reports
571
+
572
+ def trades_to_dataframe(self, trades: List[InsiderTrade]) -> pd.DataFrame:
573
+ """Convert list of InsiderTrade objects to pandas DataFrame"""
574
+ if not trades:
575
+ return pd.DataFrame()
576
+
577
+ data = [trade.__dict__ for trade in trades]
578
+
579
+ df = pd.DataFrame(data)
580
+ # Convert date column with error handling
581
+ df['Transaction Date'] = pd.to_datetime(df['Transaction Date'], errors='coerce')
582
+ return df
583
+
584
+
585
+ # Example usage functions
586
+ async def example_single_symbol():
587
+ """Example: Get report for single symbol"""
588
+ aggregator = InsiderTradingAggregator()
589
+
590
+ # Set your API keys here (use environment variables in a real app)
591
+ # aggregator.set_api_key('fmp', 'your_fmp_api_key')
592
+ # aggregator.set_api_key('sec_api', 'your_sec_api_key')
593
+
594
+ symbol = 'AAPL'
595
+ logger.info(f"--- Running single symbol example for ${symbol} ---")
596
+ trades = await aggregator.get_all_insider_trades(symbol, filter_days=30)
597
+ report = aggregator.generate_trading_report(trades)
598
+
599
+ if report:
600
+ telegram_msg = aggregator.format_telegram_message(report)
601
+ print("\n--- Full Telegram Message ---")
602
+ print(telegram_msg)
603
+ else:
604
+ print(f"No recent trades found for {symbol}")
605
+
606
+
607
+ async def example_multiple_symbols():
608
+ """Example: Get reports for multiple symbols"""
609
+ aggregator = InsiderTradingAggregator()
610
+
611
+ symbols = ['AAPL', 'GOOGL', 'MSFT', 'TSLA']
612
+ logger.info(f"\n--- Running multiple symbols example for {', '.join(symbols)} ---")
613
+ reports = await aggregator.get_multiple_symbols_report(symbols, filter_days=30)
614
+
615
+ for symbol, report in reports.items():
616
+ print(f"\n--- Report for ${symbol} ---")
617
+ if report and report.total_trades > 0:
618
+ short_msg = aggregator.format_telegram_message_short(report)
619
+ print(short_msg)
620
+ else:
621
+ print(f"No recent trades found for {symbol}")
622
+
623
+
624
+ async def main():
625
+ await example_single_symbol()
626
+ print("\n" + "=" * 50)
627
+ await example_multiple_symbols()
628
+
629
+
630
+ if __name__ == "__main__":
631
+ # In some environments (like Windows), this policy is needed for aiohttp
632
+ # asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
633
+ asyncio.run(main())
src/api/insiders/trading_report.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from typing import List, Tuple
4
+
5
+ from src.api.insiders.insider_trade import InsiderTrade
6
+
7
+
8
+ @dataclass
9
+ class TradingReport:
10
+ """Container for trading report data"""
11
+ symbol: str
12
+ company_name: str
13
+ total_trades: int
14
+ buy_trades: int
15
+ sell_trades: int
16
+ total_value_bought: float
17
+ total_value_sold: float
18
+ net_value: float
19
+ top_insiders: List[Tuple[str, int, float]] # (name, trade_count, total_value)
20
+ period_start: datetime
21
+ period_end: datetime
22
+ trades: List[InsiderTrade] = field(default_factory=list)
src/api/insiders/transaction_types.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+
4
+ class TransactionType(Enum):
5
+ """Enum for transaction types"""
6
+ BUY = "Buy"
7
+ SELL = "Sell"
8
+ GRANT = "Grant"
9
+ EXERCISE = "Exercise"
10
+ OTHER = "Other"
src/telegram_bot/telegram_bot_service.py CHANGED
@@ -22,6 +22,8 @@ from src.services.async_stock_price_predictor import AsyncStockPricePredictor, h
22
  from src.services.stock_predictor import AsyncStockPredictor
23
  from src.services.async_trading_grid_calculator import generate_grid_message
24
  from src.core.fundamental_analysis.async_fundamental_analyzer import AsyncFundamentalAnalyzer
 
 
25
 
26
 
27
  class TelegramBotService:
@@ -182,6 +184,8 @@ class TelegramBotService:
182
  response += "Aggressive - high return, 12 levels, drop up to 50%\n"
183
  response += "Ultra aggressive - maximum aggressiveness, 15 levels, drop up to 70%\n"
184
  response += "/fa TICKER - Fundamental analysis report (e.g., /fa AAPL)\n"
 
 
185
 
186
  elif base_command == "/status":
187
  response = "βœ… <b>Bot Status: Online</b>\n\n"
@@ -225,6 +229,10 @@ class TelegramBotService:
225
  await self.handle_fa_command(ticker="NVDA", chat_id=chat_id, command_parts=command_parts,
226
  text=None, user_name=user_name)
227
  return
 
 
 
 
228
 
229
  else:
230
  response = f"❓ Unknown command: {command}\n\n"
@@ -620,3 +628,26 @@ class TelegramBotService:
620
  report = await analyzer.generate_report(sort_by="P/E", dcf_growth=0.05,
621
  dcf_discount=0.10, dcf_years=5)
622
  await self.send_message_via_proxy(chat_id, report)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  from src.services.stock_predictor import AsyncStockPredictor
23
  from src.services.async_trading_grid_calculator import generate_grid_message
24
  from src.core.fundamental_analysis.async_fundamental_analyzer import AsyncFundamentalAnalyzer
25
+ from src.api.insiders.insider_trading_aggregator import InsiderTradingAggregator
26
+ from src.telegram_bot.logger import main_logger as logger
27
 
28
 
29
  class TelegramBotService:
 
184
  response += "Aggressive - high return, 12 levels, drop up to 50%\n"
185
  response += "Ultra aggressive - maximum aggressiveness, 15 levels, drop up to 70%\n"
186
  response += "/fa TICKER - Fundamental analysis report (e.g., /fa AAPL)\n"
187
+ response += "/insiders - Provides key insider's trades\n"
188
+ response += "/insiders NVDA 30 - Insider's trades for the last 30 days\n"
189
 
190
  elif base_command == "/status":
191
  response = "βœ… <b>Bot Status: Online</b>\n\n"
 
229
  await self.handle_fa_command(ticker="NVDA", chat_id=chat_id, command_parts=command_parts,
230
  text=None, user_name=user_name)
231
  return
232
+ elif base_command == "/insiders":
233
+ await self.handle_insiders_command(ticker="NVDA", chat_id=chat_id, command_parts=command_parts,
234
+ text=None, user_name=user_name)
235
+ return
236
 
237
  else:
238
  response = f"❓ Unknown command: {command}\n\n"
 
628
  report = await analyzer.generate_report(sort_by="P/E", dcf_growth=0.05,
629
  dcf_discount=0.10, dcf_years=5)
630
  await self.send_message_via_proxy(chat_id, report)
631
+
632
+ async def handle_insiders_command(
633
+ self, ticker: str, chat_id: int, command_parts: list[str], text: str | None, user_name: str
634
+ ) -> None:
635
+ """Insider trades command handler"""
636
+ ticker = 'NVDA' # Default ticker if not specified
637
+ last_days = 30 # Default days
638
+ if len(command_parts) == 2:
639
+ ticker = command_parts[1].upper()
640
+ last_days = command_parts[2]
641
+ aggregator = InsiderTradingAggregator()
642
+ logger.info(f"--- Running single ticker analysis for ${ticker} (insider trades) ---")
643
+ trades = await aggregator.get_all_insider_trades(ticker, filter_days=last_days)
644
+ report = aggregator.generate_trading_report(trades)
645
+ if report and report.total_trades > 0:
646
+ #telegram_msg = aggregator.format_telegram_message(report)
647
+ #logger.info("\n--- Full Telegram Message ---")
648
+ #logger.info(telegram_msg)
649
+ short_msg = aggregator.format_telegram_message_short(report)
650
+ await self.send_message_via_proxy(chat_id, short_msg)
651
+ else:
652
+ logger.info(f"No recent trades found for {ticker}")
653
+ await self.send_message_via_proxy(chat_id,f"No recent trades found for {ticker}")