Dmitry Beresnev commited on
Commit
a8327d8
·
1 Parent(s): 919a427

fix async fundamental analyzer

Browse files
src/core/fundamental_analysis/async_fundamental_analyzer.py CHANGED
@@ -2,7 +2,6 @@ import asyncio
2
  from typing import Dict, Any, List, Optional
3
 
4
  import pandas as pd
5
- import yfinance as yf
6
 
7
  from src.core.fundamental_analysis.core_models import TickerData, FinancialMetrics, DCFResult
8
  from src.core.fundamental_analysis.async_data_fetcher import AsyncDataFetcher
@@ -15,59 +14,66 @@ from src.core.fundamental_analysis.financial_utils import FinancialUtils
15
 
16
 
17
  class AsyncFundamentalAnalyzer:
18
- """Main async class for fundamental analysis"""
 
 
 
19
 
20
  def __init__(self, ticker: str, max_workers: int = 5):
21
  self.ticker = ticker.upper()
 
22
  self.data_fetcher = AsyncDataFetcher(max_workers=max_workers)
23
  self.dcf_calculator = DCFCalculator()
24
  self.report_generator = ReportGenerator()
25
  self.peer_comparison = PeerComparison(self.data_fetcher)
26
-
27
- # Cache for ticker data
28
  self._ticker_data: Optional[TickerData] = None
29
 
30
  async def _ensure_ticker_data(self) -> TickerData:
31
- """Ensure ticker data is loaded"""
32
  if self._ticker_data is None:
33
  self._ticker_data = await self.data_fetcher.fetch_ticker_data(self.ticker)
34
  return self._ticker_data
35
 
36
  async def calculate_metrics(self) -> FinancialMetrics:
37
- """Calculate all financial metrics asynchronously"""
38
  ticker_data = await self._ensure_ticker_data()
39
  data_extractor = DataExtractor(ticker_data)
40
  metrics_calculator = MetricsCalculator(data_extractor)
41
  return await metrics_calculator.calculate_all_metrics()
42
 
43
- async def calculate_dcf(self, base_fcf: Optional[float], growth_rate: float = 0.05,
44
- discount_rate: float = 0.10, projection_years: int = 5,
45
- shares_outstanding: Optional[float] = None) -> DCFResult:
46
- """Calculate DCF valuation asynchronously"""
47
- return await self.dcf_calculator.calculate_dcf(
48
- base_fcf=base_fcf,
 
 
 
 
49
  growth_rate=growth_rate,
50
  discount_rate=discount_rate,
51
  projection_years=projection_years,
52
- shares_outstanding=shares_outstanding
53
  )
54
 
55
  async def compare_with_peers(self, sort_by: str = "P/E") -> pd.DataFrame:
56
- """Compare with MAG7 peers asynchronously"""
57
  return await self.peer_comparison.compare_with_mag7(self.ticker, sort_by)
58
 
59
  async def generate_report(self, sort_by: str = "P/E", dcf_growth: float = 0.05,
60
  dcf_discount: float = 0.10, dcf_years: int = 5) -> str:
61
- """Generate comprehensive fundamental analysis report asynchronously"""
62
- # Run all operations concurrently where possible
63
  metrics_task = self.calculate_metrics()
64
  peer_comparison_task = self.compare_with_peers(sort_by=sort_by)
65
 
66
- # Wait for metrics first as DCF depends on it
67
- metrics = await metrics_task
68
 
69
- # Calculate DCF and wait for peer comparison
70
- dcf_task = self.calculate_dcf(
 
71
  base_fcf=metrics.free_cash_flow,
72
  growth_rate=dcf_growth,
73
  discount_rate=dcf_discount,
@@ -75,8 +81,6 @@ class AsyncFundamentalAnalyzer:
75
  shares_outstanding=metrics.shares_outstanding
76
  )
77
 
78
- dcf_result, peer_comparison = await asyncio.gather(dcf_task, peer_comparison_task)
79
-
80
  return await self.report_generator.generate_telegram_report(
81
  ticker=self.ticker,
82
  metrics=metrics,
@@ -85,844 +89,79 @@ class AsyncFundamentalAnalyzer:
85
  sort_by=sort_by
86
  )
87
 
88
- async def batch_analyze(self, tickers: List[str]) -> Dict[str, str]:
89
- """Analyze multiple tickers concurrently and return reports"""
90
- analyzers = [AsyncFundamentalAnalyzer(ticker) for ticker in tickers]
91
- tasks = [analyzer.generate_report() for analyzer in analyzers]
 
 
 
92
 
 
93
  results = await asyncio.gather(*tasks, return_exceptions=True)
94
 
95
  reports = {}
96
- for i, result in enumerate(results):
97
  if isinstance(result, Exception):
98
- reports[tickers[i]] = f"Error analyzing {tickers[i]}: {str(result)}"
99
  else:
100
- reports[tickers[i]] = result
101
-
102
  return reports
103
 
104
  async def __aenter__(self):
105
- """Async context manager entry"""
 
106
  return self
107
 
108
  async def __aexit__(self, exc_type, exc_val, exc_tb):
109
- """Async context manager exit - cleanup resources"""
110
- # Cleanup is handled by AsyncDataFetcher's __del__ method
111
- pass
112
 
 
113
 
114
- # Utility functions for easier usage
115
  async def analyze_ticker(ticker: str, **kwargs) -> str:
116
- """Convenience function to analyze a single ticker"""
117
  async with AsyncFundamentalAnalyzer(ticker) as analyzer:
118
  return await analyzer.generate_report(**kwargs)
119
 
120
 
121
- async def analyze_multiple_tickers(tickers: List[str], **kwargs) -> Dict[str, str]:
122
- """Convenience function to analyze multiple tickers concurrently"""
123
-
124
- async def analyze_single(ticker):
125
- async with AsyncFundamentalAnalyzer(ticker) as analyzer:
126
- return await analyzer.generate_report(**kwargs)
127
-
128
- tasks = [analyze_single(ticker) for ticker in tickers]
129
- results = await asyncio.gather(*tasks, return_exceptions=True)
130
-
131
- reports = {}
132
- for i, result in enumerate(results):
133
- if isinstance(result, Exception):
134
- reports[tickers[i]] = f"Error analyzing {tickers[i]}: {str(result)}"
135
- else:
136
- reports[tickers[i]] = result
137
-
138
- return reports
139
-
140
-
141
  async def quick_comparison(tickers: List[str], sort_by: str = "P/E") -> pd.DataFrame:
142
- """Quick comparison of multiple tickers"""
143
- data_fetcher = AsyncDataFetcher()
144
- peer_comparison = PeerComparison(data_fetcher)
145
-
146
- # For quick comparison, we'll fetch all tickers and create a comparison table
147
- ticker_data_dict = await data_fetcher.fetch_multiple_tickers(tickers)
148
-
149
- rows: List[Dict[str, Any]] = []
150
- utils = FinancialUtils()
151
-
152
- for ticker in tickers:
153
- if ticker not in ticker_data_dict:
154
- continue
155
 
 
156
  try:
157
- ticker_data = ticker_data_dict[ticker]
158
- info = ticker_data.info
159
-
160
- price = info.get("currentPrice") or info.get("regularMarketPrice")
161
- eps = info.get("trailingEps")
162
- book_value = info.get("bookValue")
163
- market_cap = info.get("marketCap")
164
- cash = info.get("totalCash")
165
- debt = info.get("totalDebt")
166
- ebitda = info.get("ebitda")
167
 
168
- pe = utils.safe_divide(price, eps)
169
- pb = utils.safe_divide(price, book_value)
170
- ev = (market_cap or 0) + (debt or 0) - (cash or 0) if market_cap is not None else None
171
- ev_ebitda = utils.safe_divide(ev, ebitda)
172
 
 
 
173
  rows.append({
174
  "Ticker": ticker,
175
- "Price": price,
176
- "Market Cap": market_cap,
177
- "P/E": pe,
178
- "P/B": pb,
179
- "EV/EBITDA": ev_ebitda,
 
 
180
  })
181
- except Exception as e:
182
- print(f"Error processing {ticker}: {e}")
183
- continue
184
 
185
  df = pd.DataFrame(rows)
186
  if sort_by in df.columns:
187
- df = df.sort_values(by=sort_by, na_position='last')
188
-
189
- return df.reset_index(drop=True)
190
-
191
-
192
-
193
-
194
-
195
- '''
196
-
197
- """
198
- Refactored asynchronous comprehensive fundamental analysis module with human interpretations
199
-
200
- Key fixes made:
201
- - PEG calculation fixed (handles decimals vs percentages and avoids incorrect *100 multiplication)
202
- - Free Cash Flow calculation uses absolute CAPEX sign correctly
203
- - AsyncDataFetcher implements async context manager and optional concurrency semaphore
204
- - Minor robustness improvements in extractors and metric calculator
205
- - Removed unused imports
206
-
207
- Dependencies: yfinance, pandas, numpy, aiohttp (optional), asyncio
208
- """
209
-
210
- from __future__ import annotations
211
- import asyncio
212
- from concurrent.futures import ThreadPoolExecutor
213
- from typing import Dict, Any, List, Optional
214
- from dataclasses import dataclass
215
- from enum import Enum
216
-
217
- import numpy as np
218
- import pandas as pd
219
- import yfinance as yf
220
-
221
- # Constants
222
- MAG7_TICKERS = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA"]
223
-
224
-
225
- class MetricCategory(Enum):
226
- """Categories for financial metrics"""
227
- VALUATION = "valuation"
228
- PROFITABILITY = "profitability"
229
- LEVERAGE = "leverage"
230
- CASH_FLOW = "cash_flow"
231
-
232
-
233
- @dataclass
234
- class FinancialMetrics:
235
- """Container for calculated financial metrics"""
236
- price: Optional[float] = None
237
- market_cap: Optional[float] = None
238
- shares_outstanding: Optional[float] = None
239
- pe_ratio: Optional[float] = None
240
- pb_ratio: Optional[float] = None
241
- peg_ratio: Optional[float] = None
242
- ev_ebitda: Optional[float] = None
243
- ps_ratio: Optional[float] = None
244
- pcf_ratio: Optional[float] = None
245
- ev_sales: Optional[float] = None
246
- dividend_yield: Optional[float] = None
247
- roe: Optional[float] = None
248
- roa: Optional[float] = None
249
- roic: Optional[float] = None
250
- roce: Optional[float] = None
251
- gross_margin: Optional[float] = None
252
- operating_margin: Optional[float] = None
253
- net_margin: Optional[float] = None
254
- ebitda_margin: Optional[float] = None
255
- current_ratio: Optional[float] = None
256
- quick_ratio: Optional[float] = None
257
- cash_ratio: Optional[float] = None
258
- ocf_to_current_liabilities: Optional[float] = None
259
- asset_turnover: Optional[float] = None
260
- inventory_turnover: Optional[float] = None
261
- receivables_turnover: Optional[float] = None
262
- days_sales_outstanding: Optional[float] = None
263
- debt_to_equity: Optional[float] = None
264
- debt_to_assets: Optional[float] = None
265
- equity_ratio: Optional[float] = None
266
- debt_to_capital: Optional[float] = None
267
- interest_coverage: Optional[float] = None
268
- operating_cash_flow: Optional[float] = None
269
- capex: Optional[float] = None
270
- free_cash_flow: Optional[float] = None
271
- fcf_yield: Optional[float] = None
272
- quality_of_earnings: Optional[float] = None
273
- revenue_growth: Optional[float] = None
274
- earnings_growth: Optional[float] = None
275
- book_value_growth: Optional[float] = None
276
- altman_z_score: Optional[float] = None
277
- piotroski_score: Optional[int] = None
278
- enterprise_value: Optional[float] = None
279
- book_value: Optional[float] = None
280
- eps: Optional[float] = None
281
-
282
-
283
- @dataclass
284
- class DCFResult:
285
- fair_value_per_share: Optional[float] = None
286
- intrinsic_value: Optional[float] = None
287
- upside_downside: Optional[float] = None
288
-
289
-
290
- @dataclass
291
- class TickerData:
292
- ticker: str
293
- info: Dict[str, Any]
294
- financials: pd.DataFrame
295
- balance_sheet: pd.DataFrame
296
- cashflow: pd.DataFrame
297
-
298
-
299
- class FinancialUtils:
300
- @staticmethod
301
- def safe_divide(numerator: Optional[float], denominator: Optional[float]) -> Optional[float]:
302
- try:
303
- if numerator is None or denominator is None:
304
- return None
305
- if isinstance(numerator, (int, float, np.number)) and isinstance(denominator, (int, float, np.number)):
306
- if denominator == 0:
307
- return None
308
- return float(numerator) / float(denominator)
309
- except Exception:
310
- return None
311
- return None
312
-
313
- @staticmethod
314
- def format_number(value: Optional[float], decimal_places: int = 2, suffix: str = "") -> str:
315
- if value is None or (isinstance(value, float) and (np.isnan(value) or np.isinf(value))):
316
- return "—"
317
- try:
318
- abs_value = abs(value)
319
- if abs_value >= 1_000_000_000_000:
320
- return f"{value/1_000_000_000_000:.{decimal_places}f}T{suffix}"
321
- elif abs_value >= 1_000_000_000:
322
- return f"{value/1_000_000_000:.{decimal_places}f}B{suffix}"
323
- elif abs_value >= 1_000_000:
324
- return f"{value/1_000_000:.{decimal_places}f}M{suffix}"
325
- elif abs_value >= 1_000:
326
- return f"{value/1_000:.{decimal_places}f}K{suffix}"
327
- else:
328
- return f"{value:.{decimal_places}f}{suffix}"
329
- except Exception:
330
- return "—"
331
-
332
- @staticmethod
333
- def format_percentage(value: Optional[float], decimal_places: int = 2) -> str:
334
- if value is None or (isinstance(value, float) and (np.isnan(value) or np.isinf(value))):
335
- return "—"
336
- try:
337
- return f"{value*100:.{decimal_places}f}%"
338
- except Exception:
339
- return "—"
340
-
341
-
342
- class MetricInterpreter:
343
- # (unchanged for brevity) - keep existing interpret_* methods
344
- @staticmethod
345
- def interpret_pe_ratio(pe: Optional[float]) -> str:
346
- if pe is None or pe <= 0:
347
- return "No earnings or insufficient data"
348
- elif pe < 10:
349
- return "Cheap: market expects stagnation/risks"
350
- elif pe < 20:
351
- return "Fair valuation range"
352
- elif pe < 35:
353
- return "Premium: expects sustained growth"
354
- else:
355
- return "High premium: expects rapid growth or overheated"
356
-
357
- @staticmethod
358
- def interpret_pb_ratio(pb: Optional[float]) -> str:
359
- if pb is None or pb <= 0:
360
- return "No data or negative equity"
361
- elif pb < 1:
362
- return "Below book value: market expects problems"
363
- elif pb < 3:
364
- return "Moderate: within normal range"
365
- elif pb < 6:
366
- return "High: strong brand/margins/growth"
367
- else:
368
- return "Very high: strong growth expectations"
369
-
370
- @staticmethod
371
- def interpret_peg_ratio(peg: Optional[float]) -> str:
372
- if peg is None or peg <= 0:
373
- return "No growth data available"
374
- elif peg < 0.8:
375
- return "Cheap relative to growth"
376
- elif peg <= 1.2:
377
- return "Fair price relative to growth"
378
- else:
379
- return "Expensive relative to growth"
380
-
381
- @staticmethod
382
- def interpret_ps_ratio(ps: Optional[float]) -> str:
383
- if ps is None or ps <= 0:
384
- return "No data available"
385
- elif ps < 2:
386
- return "Reasonable sales multiple"
387
- elif ps < 5:
388
- return "Moderate sales premium"
389
- else:
390
- return "High sales premium"
391
-
392
- @staticmethod
393
- def interpret_ev_ebitda(val: Optional[float]) -> str:
394
- if val is None or val <= 0:
395
- return "No data available"
396
- elif val < 6:
397
- return "Cheap/questions about earnings quality"
398
- elif val <= 12:
399
- return "Normal range for mature businesses"
400
- else:
401
- return "High: market expects growth acceleration"
402
-
403
- @staticmethod
404
- def interpret_roe(roe: Optional[float]) -> str:
405
- if roe is None or roe <= 0:
406
- return "Low return on equity"
407
- elif roe < 0.15:
408
- return "Moderate efficiency"
409
- elif roe < 0.4:
410
- return "High efficiency"
411
- else:
412
- return "Very high: possible leverage effect"
413
-
414
- @staticmethod
415
- def interpret_roa(roa: Optional[float]) -> str:
416
- if roa is None or roa <= 0:
417
- return "Low asset efficiency"
418
- elif roa < 0.05:
419
- return "Moderate for capital-intensive industries"
420
- elif roa < 0.12:
421
- return "Good asset utilization"
422
- else:
423
- return "Excellent asset efficiency"
424
-
425
- @staticmethod
426
- def interpret_roic(roic: Optional[float]) -> str:
427
- if roic is None or roic <= 0:
428
- return "Low capital efficiency"
429
- elif roic < 0.10:
430
- return "Below cost of capital"
431
- elif roic < 0.20:
432
- return "Decent capital allocation"
433
- else:
434
- return "Excellent capital allocation"
435
-
436
- @staticmethod
437
- def interpret_current_ratio(ratio: Optional[float]) -> str:
438
- if ratio is None or ratio <= 0:
439
- return "Poor liquidity position"
440
- elif ratio < 1:
441
- return "Liquidity concerns"
442
- elif ratio < 2:
443
- return "Adequate liquidity"
444
- else:
445
- return "Strong liquidity position"
446
-
447
- @staticmethod
448
- def interpret_quick_ratio(ratio: Optional[float]) -> str:
449
- if ratio is None or ratio <= 0:
450
- return "Poor short-term liquidity"
451
- elif ratio < 1:
452
- return "Tight liquidity"
453
- else:
454
- return "Good short-term liquidity"
455
-
456
- @staticmethod
457
- def interpret_asset_turnover(turnover: Optional[float]) -> str:
458
- if turnover is None or turnover <= 0:
459
- return "Poor asset utilization"
460
- elif turnover < 0.5:
461
- return "Low asset efficiency"
462
- elif turnover < 1.5:
463
- return "Moderate asset efficiency"
464
- else:
465
- return "High asset efficiency"
466
-
467
- @staticmethod
468
- def interpret_margin(margin: Optional[float], margin_type: str) -> str:
469
- if margin is None or margin < 0:
470
- return f"Narrow/negative {margin_type} margin"
471
- if margin_type == "gross":
472
- if margin < 0.3:
473
- return "Low gross margin"
474
- elif margin < 0.6:
475
- return "Healthy gross margin"
476
- else:
477
- return "Very high gross margin"
478
- elif margin_type == "operating":
479
- if margin < 0.1:
480
- return "Low operating margin"
481
- elif margin < 0.25:
482
- return "Sustainable operating margin"
483
- else:
484
- return "High operating margin"
485
- elif margin_type == "net":
486
- if margin < 0.05:
487
- return "Thin net margin"
488
- elif margin < 0.15:
489
- return "Normal net margin"
490
- else:
491
- return "High net margin"
492
- elif margin_type == "ebitda":
493
- if margin < 0.1:
494
- return "Low EBITDA margin"
495
- elif margin < 0.25:
496
- return "Healthy EBITDA margin"
497
- else:
498
- return "High EBITDA margin"
499
- return ""
500
-
501
- @staticmethod
502
- def interpret_debt_equity(de: Optional[float]) -> str:
503
- if de is None or de < 0:
504
- return "No data available"
505
- elif de < 1:
506
- return "Moderate debt load"
507
- elif de < 2:
508
- return "Elevated debt load"
509
- else:
510
- return "High/critical debt load"
511
-
512
- @staticmethod
513
- def interpret_debt_to_assets(dta: Optional[float]) -> str:
514
- if dta is None or dta < 0:
515
- return "No data available"
516
- elif dta < 0.3:
517
- return "Conservative debt level"
518
- elif dta < 0.6:
519
- return "Moderate debt level"
520
- else:
521
- return "High debt level"
522
-
523
- @staticmethod
524
- def interpret_interest_coverage(icr: Optional[float]) -> str:
525
- if icr is None or icr <= 0:
526
- return "No/negative interest coverage"
527
- elif icr < 1.5:
528
- return "Risky: low coverage"
529
- elif icr < 3:
530
- return "Moderate coverage"
531
- else:
532
- return "Comfortable coverage"
533
-
534
- @staticmethod
535
- def interpret_fcf_yield(yield_val: Optional[float]) -> str:
536
- if yield_val is None or yield_val <= 0:
537
- return "Low/negative FCF yield"
538
- elif yield_val < 0.02:
539
- return "Low FCF yield"
540
- elif yield_val < 0.05:
541
- return "Moderate FCF yield"
542
- elif yield_val < 0.08:
543
- return "Good FCF yield"
544
- else:
545
- return "High FCF yield"
546
-
547
- @staticmethod
548
- def interpret_quality_of_earnings(qoe: Optional[float]) -> str:
549
- if qoe is None:
550
- return "No data available"
551
- elif qoe < 0.8:
552
- return "Poor earnings quality"
553
- elif qoe < 1.2:
554
- return "Good earnings quality"
555
- else:
556
- return "Excellent earnings quality"
557
-
558
- @staticmethod
559
- def interpret_altman_z_score(z_score: Optional[float]) -> str:
560
- if z_score is None:
561
- return "No data available"
562
- elif z_score < 1.8:
563
- return "High bankruptcy risk"
564
- elif z_score < 3.0:
565
- return "Moderate risk zone"
566
- else:
567
- return "Safe zone"
568
-
569
-
570
- class AsyncDataFetcher:
571
- """Async yfinance fetcher with optional concurrency throttling and context manager support."""
572
-
573
- def __init__(self, max_workers: int = 5, max_concurrency: int | None = None):
574
- self.max_workers = max_workers
575
- self.executor: Optional[ThreadPoolExecutor] = None
576
- self.semaphore = asyncio.Semaphore(max_concurrency) if max_concurrency is not None else None
577
-
578
- async def __aenter__(self):
579
- self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
580
- return self
581
-
582
- async def __aexit__(self, exc_type, exc, tb):
583
- if self.executor:
584
- self.executor.shutdown(wait=False)
585
- self.executor = None
586
-
587
- async def fetch_ticker_data(self, ticker: str) -> TickerData:
588
- loop = asyncio.get_event_loop()
589
-
590
- async def _maybe_wait():
591
- if self.semaphore:
592
- await self.semaphore.acquire()
593
-
594
- async def _maybe_release():
595
- if self.semaphore:
596
- self.semaphore.release()
597
-
598
- await _maybe_wait()
599
-
600
- def _fetch_data():
601
- try:
602
- ticker_obj = yf.Ticker(ticker)
603
- return TickerData(
604
- ticker=ticker,
605
- info=getattr(ticker_obj, "info", {}) or {},
606
- financials=getattr(ticker_obj, "financials", pd.DataFrame()),
607
- balance_sheet=getattr(ticker_obj, "balance_sheet", pd.DataFrame()),
608
- cashflow=getattr(ticker_obj, "cashflow", pd.DataFrame())
609
- )
610
- except Exception as e:
611
- # keep errors quiet in production; consider logging
612
- return TickerData(ticker=ticker, info={}, financials=pd.DataFrame(), balance_sheet=pd.DataFrame(), cashflow=pd.DataFrame())
613
-
614
- try:
615
- result = await loop.run_in_executor(self.executor, _fetch_data)
616
- finally:
617
- await _maybe_release()
618
-
619
- return result
620
-
621
- async def fetch_multiple_tickers(self, tickers: List[str]) -> Dict[str, TickerData]:
622
- tasks = [self.fetch_ticker_data(t) for t in tickers]
623
- results = await asyncio.gather(*tasks, return_exceptions=True)
624
- out: Dict[str, TickerData] = {}
625
- for r in results:
626
- if isinstance(r, Exception):
627
- continue
628
- out[r.ticker] = r
629
- return out
630
-
631
-
632
- class DataExtractor:
633
- def __init__(self, ticker_data: TickerData):
634
- self.ticker_data = ticker_data
635
- self.info = ticker_data.info or {}
636
- self.financials = ticker_data.financials or pd.DataFrame()
637
- self.balance_sheet = ticker_data.balance_sheet or pd.DataFrame()
638
- self.cashflow = ticker_data.cashflow or pd.DataFrame()
639
-
640
- def _first_non_na_from_index(self, df: pd.DataFrame, keys: List[str]) -> Optional[float]:
641
- for k in keys:
642
- if k in df.index:
643
- vals = df.loc[k].dropna()
644
- if len(vals) > 0:
645
- try:
646
- return float(vals.iloc[0])
647
- except Exception:
648
- continue
649
- return None
650
-
651
- def get_balance_sheet_item(self, item: str, fallback_names: List[str] | None = None) -> Optional[float]:
652
- if fallback_names is None:
653
- fallback_names = []
654
- keys = [item] + fallback_names
655
- try:
656
- v = self._first_non_na_from_index(self.balance_sheet, keys)
657
- if v is not None:
658
- return v
659
- except Exception:
660
- pass
661
- # fallback to info dict
662
- val = self.info.get(item)
663
- return float(val) if val is not None else None
664
-
665
- def get_financial_item(self, item: str, fallback_names: List[str] | None = None) -> Optional[float]:
666
- if fallback_names is None:
667
- fallback_names = []
668
- keys = [item] + fallback_names
669
- try:
670
- v = self._first_non_na_from_index(self.financials, keys)
671
- if v is not None:
672
- return v
673
- except Exception:
674
- pass
675
- val = self.info.get(item)
676
- return float(val) if val is not None else None
677
-
678
- def get_cashflow_item(self, item: str, fallback_names: List[str] | None = None) -> Optional[float]:
679
- if fallback_names is None:
680
- fallback_names = []
681
- keys = [item] + fallback_names
682
- try:
683
- v = self._first_non_na_from_index(self.cashflow, keys)
684
- if v is not None:
685
- return v
686
- except Exception:
687
- pass
688
- val = self.info.get(item)
689
- return float(val) if val is not None else None
690
-
691
- def get_historical_data(self, item: str, periods: int) -> List[Optional[float]]:
692
- try:
693
- if item in self.financials.index and not self.financials.empty:
694
- values = self.financials.loc[item].dropna()
695
- return [float(values.iloc[i]) if i < len(values) else None for i in range(min(periods, len(values)))]
696
- except Exception:
697
- pass
698
- return [None] * periods
699
-
700
-
701
- class DCFCalculator:
702
- @staticmethod
703
- async def calculate_dcf(base_fcf: Optional[float],
704
- growth_rate: float = 0.05,
705
- discount_rate: float = 0.10,
706
- projection_years: int = 5,
707
- shares_outstanding: Optional[float] = None) -> DCFResult:
708
- if base_fcf is None or base_fcf <= 0:
709
- return DCFResult()
710
- loop = asyncio.get_event_loop()
711
-
712
- def _calculate():
713
- future_fcfs = [base_fcf * ((1 + growth_rate) ** t) for t in range(1, projection_years + 1)]
714
- pv_fcfs = [fcf / ((1 + discount_rate) ** t) for t, fcf in enumerate(future_fcfs, 1)]
715
-
716
- if discount_rate > growth_rate:
717
- terminal_value = future_fcfs[-1] * (1 + growth_rate) / (discount_rate - growth_rate)
718
- pv_terminal = terminal_value / ((1 + discount_rate) ** projection_years)
719
- else:
720
- pv_terminal = future_fcfs[-1] / ((1 + discount_rate) ** projection_years)
721
-
722
- intrinsic_value = sum(pv_fcfs) + pv_terminal
723
- fair_value_per_share = None
724
- if shares_outstanding and shares_outstanding > 0:
725
- fair_value_per_share = intrinsic_value / shares_outstanding
726
- return DCFResult(fair_value_per_share=fair_value_per_share, intrinsic_value=intrinsic_value)
727
-
728
- return await loop.run_in_executor(None, _calculate)
729
-
730
-
731
- class MetricsCalculator:
732
- def __init__(self, data_extractor: DataExtractor):
733
- self.extractor = data_extractor
734
- self.utils = FinancialUtils()
735
-
736
- async def calculate_all_metrics(self) -> FinancialMetrics:
737
- loop = asyncio.get_event_loop()
738
-
739
- def _calculate():
740
- info = self.extractor.info or {}
741
-
742
- price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose")
743
- market_cap = info.get("marketCap")
744
- shares_out = info.get("sharesOutstanding") or info.get("impliedSharesOutstanding")
745
- eps = info.get("trailingEps") or info.get("forwardEps")
746
- book_value = info.get("bookValue")
747
-
748
- net_income = (info.get("netIncomeToCommon") or self.extractor.get_financial_item("Net Income", ["Net Income Common Stockholders", "Net Income Available To Common Stockholders"]))
749
- revenue = (info.get("totalRevenue") or self.extractor.get_financial_item("Total Revenue", ["Revenue", "Total Revenues"]))
750
- ebitda = (info.get("ebitda") or self.extractor.get_financial_item("EBITDA", ["Ebitda"]))
751
- ebit = (info.get("ebit") or self.extractor.get_financial_item("EBIT", ["Operating Income"]))
752
-
753
- equity = self.extractor.get_balance_sheet_item("Total Stockholder Equity", ["Stockholders Equity", "Total Equity", "Shareholders Equity"])
754
- assets = self.extractor.get_balance_sheet_item("Total Assets", ["Total Assets"])
755
- current_assets = self.extractor.get_balance_sheet_item("Current Assets", ["Total Current Assets"])
756
- current_liabilities = self.extractor.get_balance_sheet_item("Current Liabilities", ["Total Current Liabilities"])
757
-
758
- cash = (self.extractor.get_balance_sheet_item("Cash", ["Cash And Cash Equivalents", "Cash and Short Term Investments"]) or info.get("totalCash"))
759
- inventory = self.extractor.get_balance_sheet_item("Inventory", ["Inventories"])
760
- accounts_receivable = self.extractor.get_balance_sheet_item("Accounts Receivable", ["Net Receivables", "Receivables"])
761
-
762
- total_debt = (info.get("totalDebt") or self.extractor.get_balance_sheet_item("Total Debt", ["Long Term Debt", "Short Long Term Debt"]))
763
- liabilities = self.extractor.get_balance_sheet_item("Total Liab", ["Total Liabilities Net Minority Interest"])
764
-
765
- interest_expense = self.extractor.get_financial_item("Interest Expense", ["Interest Expense Non Operating"])
766
-
767
- operating_cash_flow = self.extractor.get_cashflow_item("Total Cash From Operating Activities", ["Operating Cash Flow", "Cash From Operating Activities"])
768
- capex = self.extractor.get_cashflow_item("Capital Expenditures", ["Capital Expenditure", "Purchase Of Property Plant Equipment"])
769
-
770
- if capex is not None:
771
- capex = abs(capex) # ensure capex treated as cash outflow magnitude
772
-
773
- earnings_growth = info.get("earningsQuarterlyGrowth") or info.get("earningsGrowth")
774
- revenue_growth = info.get("revenueGrowth")
775
-
776
- historical_revenue = self.extractor.get_historical_data("Total Revenue", 2)
777
- if len(historical_revenue) >= 2 and historical_revenue[0] and historical_revenue[1]:
778
- hist_growth = (historical_revenue[0] - historical_revenue[1]) / abs(historical_revenue[1])
779
- revenue_growth = revenue_growth or hist_growth
780
-
781
- gross_margin = info.get("grossMargins")
782
- operating_margin = info.get("operatingMargins")
783
- profit_margins = info.get("profitMargins")
784
-
785
- if revenue and revenue > 0:
786
- if not operating_margin and ebit:
787
- operating_margin = ebit / revenue
788
- if not profit_margins and net_income:
789
- profit_margins = net_income / revenue
790
-
791
- dividend_yield = info.get("dividendYield")
792
-
793
- enterprise_value = None
794
- if market_cap is not None:
795
- enterprise_value = market_cap + (total_debt or 0) - (cash or 0)
796
-
797
- free_cash_flow = None
798
- if operating_cash_flow is not None:
799
- if capex is not None:
800
- free_cash_flow = operating_cash_flow - capex
801
- else:
802
- free_cash_flow = operating_cash_flow
803
-
804
- pe_ratio = self.utils.safe_divide(price, eps)
805
- pb_ratio = self.utils.safe_divide(price, book_value)
806
-
807
- # PEG ratio: ensure earnings_growth is in decimal (e.g., 0.12 for 12%)
808
- peg_ratio = None
809
- if pe_ratio is not None and earnings_growth is not None:
810
- try:
811
- g = float(earnings_growth)
812
- # If Yahoo returns large numbers like 12 for 12%, convert
813
- if abs(g) > 1:
814
- g = g / 100.0
815
- # ignore extremely small growth rates that would distort PEG
816
- if abs(g) >= 0.01:
817
- peg_ratio = self.utils.safe_divide(pe_ratio, g)
818
- except Exception:
819
- peg_ratio = None
820
-
821
- ps_ratio = self.utils.safe_divide(market_cap, revenue)
822
- pcf_ratio = self.utils.safe_divide(market_cap, operating_cash_flow)
823
- ev_ebitda = self.utils.safe_divide(enterprise_value, ebitda)
824
- ev_sales = self.utils.safe_divide(enterprise_value, revenue)
825
-
826
- roe = self.utils.safe_divide(net_income, equity)
827
- roa = self.utils.safe_divide(net_income, assets)
828
-
829
- invested_capital = (equity or 0) + (total_debt or 0)
830
- roic = self.utils.safe_divide(net_income, invested_capital) if invested_capital > 0 else None
831
- capital_employed = (assets or 0) - (current_liabilities or 0)
832
- roce = self.utils.safe_divide(ebit, capital_employed) if capital_employed > 0 else None
833
-
834
- net_margin = self.utils.safe_divide(net_income, revenue)
835
- ebitda_margin = self.utils.safe_divide(ebitda, revenue)
836
-
837
- current_ratio = self.utils.safe_divide(current_assets, current_liabilities)
838
- quick_assets = (current_assets or 0) - (inventory or 0) if current_assets is not None and inventory is not None else current_assets
839
- quick_ratio = self.utils.safe_divide(quick_assets, current_liabilities)
840
- cash_ratio = self.utils.safe_divide(cash, current_liabilities)
841
- ocf_to_current_liabilities = self.utils.safe_divide(operating_cash_flow, current_liabilities)
842
-
843
- # Efficiency ratios: prefer cost of revenue for inventory turnover if available
844
- cost_of_revenue = info.get("costOfRevenue") or self.extractor.get_financial_item("Cost of Revenue", ["CostOfRevenue"])
845
- asset_turnover = self.utils.safe_divide(revenue, assets)
846
- inventory_turnover = None
847
- if cost_of_revenue is not None and inventory is not None:
848
- inventory_turnover = self.utils.safe_divide(cost_of_revenue, inventory)
849
- elif revenue is not None and inventory is not None and inventory != 0:
850
- inventory_turnover = self.utils.safe_divide(revenue, inventory) # fallback
851
-
852
- receivables_turnover = None
853
- if revenue is not None and accounts_receivable is not None:
854
- receivables_turnover = self.utils.safe_divide(revenue, accounts_receivable)
855
-
856
- days_sales_outstanding = None
857
- if receivables_turnover and receivables_turnover != 0:
858
- days_sales_outstanding = 365.0 / receivables_turnover
859
-
860
- debt_to_equity = self.utils.safe_divide(total_debt, equity)
861
- debt_to_assets = self.utils.safe_divide(total_debt, assets)
862
- equity_ratio = self.utils.safe_divide(equity, assets)
863
- debt_to_capital = None
864
- if total_debt is not None and equity is not None:
865
- denom = (total_debt + equity)
866
- debt_to_capital = self.utils.safe_divide(total_debt, denom) if denom != 0 else None
867
-
868
- interest_coverage = self.utils.safe_divide(ebit, interest_expense)
869
-
870
- fcf_yield = None
871
- if free_cash_flow is not None and market_cap:
872
- fcf_yield = self.utils.safe_divide(free_cash_flow, market_cap)
873
-
874
- quality_of_earnings = None
875
- if operating_cash_flow is not None and net_income is not None and net_income != 0:
876
- quality_of_earnings = self.utils.safe_divide(operating_cash_flow, net_income)
877
-
878
- fm = FinancialMetrics(
879
- price=price,
880
- market_cap=market_cap,
881
- shares_outstanding=shares_out,
882
- pe_ratio=pe_ratio,
883
- pb_ratio=pb_ratio,
884
- peg_ratio=peg_ratio,
885
- ev_ebitda=ev_ebitda,
886
- ps_ratio=ps_ratio,
887
- pcf_ratio=pcf_ratio,
888
- ev_sales=ev_sales,
889
- dividend_yield=dividend_yield,
890
- roe=roe,
891
- roa=roa,
892
- roic=roic,
893
- roce=roce,
894
- gross_margin=gross_margin,
895
- operating_margin=operating_margin,
896
- net_margin=net_margin,
897
- ebitda_margin=ebitda_margin,
898
- current_ratio=current_ratio,
899
- quick_ratio=quick_ratio,
900
- cash_ratio=cash_ratio,
901
- ocf_to_current_liabilities=ocf_to_current_liabilities,
902
- asset_turnover=asset_turnover,
903
- inventory_turnover=inventory_turnover,
904
- receivables_turnover=receivables_turnover,
905
- days_sales_outstanding=days_sales_outstanding,
906
- debt_to_equity=debt_to_equity,
907
- debt_to_assets=debt_to_assets,
908
- equity_ratio=equity_ratio,
909
- debt_to_capital=debt_to_capital,
910
- interest_coverage=interest_coverage,
911
- operating_cash_flow=operating_cash_flow,
912
- capex=capex,
913
- free_cash_flow=free_cash_flow,
914
- fcf_yield=fcf_yield,
915
- quality_of_earnings=quality_of_earnings,
916
- revenue_growth=revenue_growth,
917
- earnings_growth=earnings_growth,
918
- enterprise_value=enterprise_value,
919
- book_value=book_value,
920
- eps=eps
921
- )
922
-
923
- return fm
924
-
925
- return await loop.run_in_executor(None, _calculate)
926
-
927
 
928
- '''
 
2
  from typing import Dict, Any, List, Optional
3
 
4
  import pandas as pd
 
5
 
6
  from src.core.fundamental_analysis.core_models import TickerData, FinancialMetrics, DCFResult
7
  from src.core.fundamental_analysis.async_data_fetcher import AsyncDataFetcher
 
14
 
15
 
16
  class AsyncFundamentalAnalyzer:
17
+ """
18
+ Main orchestrator for performing asynchronous fundamental analysis on a stock ticker.
19
+ Manages data fetching, metric calculation, valuation, and reporting.
20
+ """
21
 
22
  def __init__(self, ticker: str, max_workers: int = 5):
23
  self.ticker = ticker.upper()
24
+ # Each analyzer instance manages its own data fetcher
25
  self.data_fetcher = AsyncDataFetcher(max_workers=max_workers)
26
  self.dcf_calculator = DCFCalculator()
27
  self.report_generator = ReportGenerator()
28
  self.peer_comparison = PeerComparison(self.data_fetcher)
 
 
29
  self._ticker_data: Optional[TickerData] = None
30
 
31
  async def _ensure_ticker_data(self) -> TickerData:
32
+ """Lazily fetches and caches the ticker's core financial data."""
33
  if self._ticker_data is None:
34
  self._ticker_data = await self.data_fetcher.fetch_ticker_data(self.ticker)
35
  return self._ticker_data
36
 
37
  async def calculate_metrics(self) -> FinancialMetrics:
38
+ """Calculates a comprehensive set of financial metrics."""
39
  ticker_data = await self._ensure_ticker_data()
40
  data_extractor = DataExtractor(ticker_data)
41
  metrics_calculator = MetricsCalculator(data_extractor)
42
  return await metrics_calculator.calculate_all_metrics()
43
 
44
+ # This method can remain async to be compatible with asyncio.gather
45
+ async def calculate_dcf(self, metrics: FinancialMetrics, growth_rate: float,
46
+ discount_rate: float, projection_years: int) -> DCFResult:
47
+ """
48
+ Wraps the synchronous DCF calculation.
49
+ This method is async to allow it to be seamlessly used in asyncio.gather streams.
50
+ """
51
+ # FIX: Removed 'await' as dcf_calculator.calculate_dcf is a synchronous method.
52
+ return self.dcf_calculator.calculate_dcf(
53
+ base_fcf=metrics.free_cash_flow,
54
  growth_rate=growth_rate,
55
  discount_rate=discount_rate,
56
  projection_years=projection_years,
57
+ shares_outstanding=metrics.shares_outstanding
58
  )
59
 
60
  async def compare_with_peers(self, sort_by: str = "P/E") -> pd.DataFrame:
61
+ """Generates a peer comparison DataFrame against the MAG7 stocks."""
62
  return await self.peer_comparison.compare_with_mag7(self.ticker, sort_by)
63
 
64
  async def generate_report(self, sort_by: str = "P/E", dcf_growth: float = 0.05,
65
  dcf_discount: float = 0.10, dcf_years: int = 5) -> str:
66
+ """Generates a comprehensive fundamental analysis report."""
67
+ # FIX: Run independent I/O-bound tasks concurrently for maximum efficiency.
68
  metrics_task = self.calculate_metrics()
69
  peer_comparison_task = self.compare_with_peers(sort_by=sort_by)
70
 
71
+ # Await both tasks together
72
+ metrics, peer_comparison = await asyncio.gather(metrics_task, peer_comparison_task)
73
 
74
+ # The DCF calculation is CPU-bound and fast, so it doesn't need its own task.
75
+ # It can be called directly after its dependencies (metrics) are ready.
76
+ dcf_result = self.dcf_calculator.calculate_dcf(
77
  base_fcf=metrics.free_cash_flow,
78
  growth_rate=dcf_growth,
79
  discount_rate=dcf_discount,
 
81
  shares_outstanding=metrics.shares_outstanding
82
  )
83
 
 
 
84
  return await self.report_generator.generate_telegram_report(
85
  ticker=self.ticker,
86
  metrics=metrics,
 
89
  sort_by=sort_by
90
  )
91
 
92
+ @staticmethod
93
+ async def batch_analyze(tickers: List[str]) -> Dict[str, str]:
94
+ """Analyzes multiple tickers concurrently and returns a dictionary of reports."""
95
+
96
+ async def _analyze_one(ticker):
97
+ async with AsyncFundamentalAnalyzer(ticker) as analyzer:
98
+ return await analyzer.generate_report()
99
 
100
+ tasks = [_analyze_one(ticker) for ticker in tickers]
101
  results = await asyncio.gather(*tasks, return_exceptions=True)
102
 
103
  reports = {}
104
+ for ticker, result in zip(tickers, results):
105
  if isinstance(result, Exception):
106
+ reports[ticker] = f"Error analyzing {ticker}: {result}"
107
  else:
108
+ reports[ticker] = result
 
109
  return reports
110
 
111
  async def __aenter__(self):
112
+ """Async context manager entry: enters the data_fetcher context."""
113
+ await self.data_fetcher.__aenter__()
114
  return self
115
 
116
  async def __aexit__(self, exc_type, exc_val, exc_tb):
117
+ """Async context manager exit: properly exits the data_fetcher context."""
118
+ await self.data_fetcher.__aexit__(exc_type, exc_val, exc_tb)
119
+
120
 
121
+ # --- Convenience Functions ---
122
 
 
123
  async def analyze_ticker(ticker: str, **kwargs) -> str:
124
+ """Convenience function to analyze and generate a report for a single ticker."""
125
  async with AsyncFundamentalAnalyzer(ticker) as analyzer:
126
  return await analyzer.generate_report(**kwargs)
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  async def quick_comparison(tickers: List[str], sort_by: str = "P/E") -> pd.DataFrame:
130
+ """
131
+ Generates a comparison DataFrame for a list of tickers using key metrics.
132
+ FIX: This function now reuses the existing calculation modules to ensure consistency.
133
+ """
134
+ rows = []
 
 
 
 
 
 
 
 
135
 
136
+ async def get_metrics_for_ticker(ticker):
137
  try:
138
+ async with AsyncFundamentalAnalyzer(ticker) as analyzer:
139
+ return await analyzer.calculate_metrics()
140
+ except Exception as e:
141
+ print(f"Could not fetch metrics for {ticker}: {e}")
142
+ return None
 
 
 
 
 
143
 
144
+ tasks = [get_metrics_for_ticker(ticker) for ticker in tickers]
145
+ results = await asyncio.gather(*tasks)
 
 
146
 
147
+ for ticker, metrics in zip(tickers, results):
148
+ if metrics:
149
  rows.append({
150
  "Ticker": ticker,
151
+ "Price": metrics.price,
152
+ "Market Cap": metrics.market_cap,
153
+ "P/E": metrics.pe_ratio,
154
+ "P/B": metrics.pb_ratio,
155
+ "EV/EBITDA": metrics.ev_ebitda,
156
+ "Net Margin": metrics.net_margin,
157
+ "ROE": metrics.roe
158
  })
159
+
160
+ if not rows:
161
+ return pd.DataFrame()
162
 
163
  df = pd.DataFrame(rows)
164
  if sort_by in df.columns:
165
+ df = df.sort_values(by=sort_by, na_position='last', ignore_index=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
+ return df