borsapy 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,376 @@
1
+ """HedeFiyat provider for analyst price targets."""
2
+
3
+ import re
4
+
5
+ from borsapy._providers.base import BaseProvider
6
+
7
+
8
+ class HedeFiyatProvider(BaseProvider):
9
+ """
10
+ Provider for analyst price targets from hedeffiyat.com.tr.
11
+
12
+ HedeFiyat aggregates analyst price targets from 30+ Turkish
13
+ financial institutions.
14
+ """
15
+
16
+ BASE_URL = "https://www.hedeffiyat.com.tr"
17
+ SEARCH_URL = "https://www.hedeffiyat.com.tr/arama"
18
+ CACHE_DURATION = 86400 # 24 hours
19
+
20
+ def __init__(self):
21
+ super().__init__()
22
+ self._url_cache: dict[str, str] = {}
23
+
24
+ def get_price_targets(self, symbol: str) -> dict[str, float | int | None]:
25
+ """
26
+ Get analyst price targets for a stock.
27
+
28
+ Args:
29
+ symbol: Stock symbol (e.g., "THYAO").
30
+
31
+ Returns:
32
+ Dictionary with:
33
+ - current: Current stock price
34
+ - low: Lowest analyst target
35
+ - high: Highest analyst target
36
+ - mean: Average target
37
+ - median: Median target
38
+ - numberOfAnalysts: Number of analysts
39
+ """
40
+ symbol = symbol.upper().replace(".IS", "").replace(".E", "")
41
+
42
+ # Check cache
43
+ cache_key = f"hedeffiyat_{symbol}"
44
+ cached = self._cache_get(cache_key)
45
+ if cached is not None:
46
+ return cached
47
+
48
+ result = {
49
+ "current": None,
50
+ "low": None,
51
+ "high": None,
52
+ "mean": None,
53
+ "median": None,
54
+ "numberOfAnalysts": None,
55
+ }
56
+
57
+ try:
58
+ # Get stock page URL
59
+ page_url = self._get_stock_url(symbol)
60
+ if not page_url:
61
+ return result
62
+
63
+ # Fetch stock page
64
+ headers = {
65
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
66
+ "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
67
+ "Upgrade-Insecure-Requests": "1",
68
+ }
69
+ response = self._client.get(page_url, headers=headers, timeout=15)
70
+ # Server may return non-200 status but still serve content
71
+ html = response.text
72
+ if not html:
73
+ return result
74
+
75
+ # Parse price data from HTML
76
+ result = self._parse_price_targets(html)
77
+
78
+ # Cache the result
79
+ if result.get("numberOfAnalysts"):
80
+ self._cache_set(cache_key, result, self.CACHE_DURATION)
81
+
82
+ return result
83
+
84
+ except Exception:
85
+ return result
86
+
87
+ def get_recommendations_summary(self, symbol: str) -> dict[str, int]:
88
+ """
89
+ Get analyst recommendation summary (buy/hold/sell counts).
90
+
91
+ Parses individual analyst recommendations from hedeffiyat.com.tr
92
+ and aggregates them into strongBuy, buy, hold, sell, strongSell counts.
93
+
94
+ Recommendation mapping:
95
+ - strongBuy: "Güçlü Al"
96
+ - buy: "Al", "Endeks Üstü Getiri", "Endeks Üstü Get."
97
+ - hold: "Tut", "Nötr", "Endekse Paralel"
98
+ - sell: "Sat", "Endeks Altı Getiri", "Endeks Altı Get."
99
+ - strongSell: "Güçlü Sat"
100
+
101
+ Args:
102
+ symbol: Stock symbol (e.g., "THYAO").
103
+
104
+ Returns:
105
+ Dictionary with counts for each recommendation category.
106
+ """
107
+ symbol = symbol.upper().replace(".IS", "").replace(".E", "")
108
+
109
+ # Check cache
110
+ cache_key = f"hedeffiyat_recsummary_{symbol}"
111
+ cached = self._cache_get(cache_key)
112
+ if cached is not None:
113
+ return cached
114
+
115
+ result = {
116
+ "strongBuy": 0,
117
+ "buy": 0,
118
+ "hold": 0,
119
+ "sell": 0,
120
+ "strongSell": 0,
121
+ }
122
+
123
+ try:
124
+ # Get stock page URL
125
+ page_url = self._get_stock_url(symbol)
126
+ if not page_url:
127
+ return result
128
+
129
+ # Fetch stock page
130
+ headers = {
131
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
132
+ "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
133
+ }
134
+ response = self._client.get(page_url, headers=headers, timeout=15)
135
+ html = response.text
136
+ if not html:
137
+ return result
138
+
139
+ # Parse recommendation buttons
140
+ # Pattern: btn-sm btn-(success|warning|danger|primary)...>RECOMMENDATION</a
141
+ # Note: btn-primary is used for "Tut" (Hold) recommendations
142
+ pattern = r'btn-sm\s+btn-(success|warning|danger|primary)[^>]*>([^<]+)</a'
143
+ matches = re.findall(pattern, html, re.IGNORECASE)
144
+
145
+ for btn_class, rec_text in matches:
146
+ rec_text = rec_text.strip().lower()
147
+ btn_class = btn_class.lower()
148
+
149
+ # Map recommendation text to category
150
+ if rec_text in ("güçlü al", "güçlü alım"):
151
+ result["strongBuy"] += 1
152
+ elif rec_text in ("al", "alım", "endeks üstü get.", "endeks üstü getiri"):
153
+ result["buy"] += 1
154
+ elif rec_text in ("tut", "tutma", "nötr", "endekse paralel"):
155
+ result["hold"] += 1
156
+ elif rec_text in ("sat", "satım", "endeks altı get.", "endeks altı getiri"):
157
+ result["sell"] += 1
158
+ elif rec_text in ("güçlü sat", "güçlü satım"):
159
+ result["strongSell"] += 1
160
+ # Fallback to button color if text doesn't match
161
+ elif btn_class == "success":
162
+ result["buy"] += 1
163
+ elif btn_class in ("warning", "primary"):
164
+ result["hold"] += 1
165
+ elif btn_class == "danger":
166
+ result["sell"] += 1
167
+
168
+ # Cache if we found any recommendations
169
+ if sum(result.values()) > 0:
170
+ self._cache_set(cache_key, result, self.CACHE_DURATION)
171
+
172
+ return result
173
+
174
+ except Exception:
175
+ return result
176
+
177
+ def _get_stock_url(self, symbol: str) -> str | None:
178
+ """
179
+ Get the hedeffiyat.com.tr URL for a stock symbol.
180
+
181
+ Args:
182
+ symbol: Stock symbol.
183
+
184
+ Returns:
185
+ Full URL or None if not found.
186
+ """
187
+ # Check URL cache
188
+ if symbol in self._url_cache:
189
+ return self._url_cache[symbol]
190
+
191
+ try:
192
+ # Fetch the stock list page to find URL mapping
193
+ headers = {
194
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
195
+ "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
196
+ "Upgrade-Insecure-Requests": "1",
197
+ }
198
+
199
+ response = self._client.get(
200
+ f"{self.BASE_URL}/senetler",
201
+ headers=headers,
202
+ timeout=15,
203
+ )
204
+ # Note: Server may return 404 but still serve content
205
+ # Only check if we got a response with content
206
+ if not response.text:
207
+ return None
208
+
209
+ # Search for the stock link in option values
210
+ # Pattern: value="/senet/thyao-turk-hava-yollari-a.o.-410"
211
+ pattern = rf'value="(/senet/{symbol.lower()}-[^"]+)"'
212
+ match = re.search(pattern, response.text, re.IGNORECASE)
213
+
214
+ if match:
215
+ url = f"{self.BASE_URL}{match.group(1)}"
216
+ self._url_cache[symbol] = url
217
+ return url
218
+
219
+ return None
220
+
221
+ except Exception:
222
+ return None
223
+
224
+ def _search_stock_url(self, symbol: str) -> str | None:
225
+ """
226
+ Search for stock URL using the search functionality.
227
+
228
+ Args:
229
+ symbol: Stock symbol.
230
+
231
+ Returns:
232
+ Stock page URL or None.
233
+ """
234
+ try:
235
+ # Use search page
236
+ headers = {
237
+ "Accept": "text/html,application/xhtml+xml",
238
+ "Accept-Language": "tr-TR,tr;q=0.9",
239
+ }
240
+
241
+ response = self._client.get(
242
+ self.SEARCH_URL,
243
+ params={"q": symbol},
244
+ headers=headers,
245
+ timeout=15,
246
+ )
247
+ response.raise_for_status()
248
+
249
+ # Look for stock link in search results
250
+ pattern = rf'href="(/senet/{symbol.lower()}-[^"]+)"'
251
+ match = re.search(pattern, response.text, re.IGNORECASE)
252
+
253
+ if match:
254
+ url = f"{self.BASE_URL}{match.group(1)}"
255
+ self._url_cache[symbol] = url
256
+ return url
257
+
258
+ return None
259
+
260
+ except Exception:
261
+ return None
262
+
263
+ def _parse_price_targets(self, html: str) -> dict[str, float | int | None]:
264
+ """
265
+ Parse price target data from HTML.
266
+
267
+ Args:
268
+ html: Page HTML content.
269
+
270
+ Returns:
271
+ Dictionary with price target data.
272
+ """
273
+ result = {
274
+ "current": None,
275
+ "low": None,
276
+ "high": None,
277
+ "mean": None,
278
+ "median": None,
279
+ "numberOfAnalysts": None,
280
+ }
281
+
282
+ try:
283
+ # Current price: "Güncel Fiyat" followed by <strong...>268,50 ₺</strong>
284
+ current_match = re.search(
285
+ r'Güncel\s*Fiyat.*?<strong[^>]*>\s*([\d.,]+)\s*₺',
286
+ html,
287
+ re.IGNORECASE | re.DOTALL,
288
+ )
289
+ if current_match:
290
+ result["current"] = self._parse_number(current_match.group(1))
291
+
292
+ # Highest target: "En Yüksek Tahmin" followed by badge with price
293
+ high_match = re.search(
294
+ r'En\s*Yüksek\s*Tahmin</div>\s*<div[^>]*>\s*([\d.,]+)\s*₺',
295
+ html,
296
+ re.IGNORECASE | re.DOTALL,
297
+ )
298
+ if high_match:
299
+ result["high"] = self._parse_number(high_match.group(1))
300
+
301
+ # Lowest target: "En Düşük Tahmin" followed by badge with price
302
+ low_match = re.search(
303
+ r'En\s*Düşük\s*Tahmin</div>\s*<div[^>]*>\s*([\d.,]+)\s*₺',
304
+ html,
305
+ re.IGNORECASE | re.DOTALL,
306
+ )
307
+ if low_match:
308
+ result["low"] = self._parse_number(low_match.group(1))
309
+
310
+ # Average price: "Ortalama Fiyat Tahmini" followed by badge
311
+ avg_match = re.search(
312
+ r'Ortalama\s*Fiyat\s*Tahmini</div>\s*<div[^>]*>\s*([\d.,]+)\s*₺',
313
+ html,
314
+ re.IGNORECASE | re.DOTALL,
315
+ )
316
+ if avg_match:
317
+ result["mean"] = self._parse_number(avg_match.group(1))
318
+
319
+ # Analyst count: "Kurum Sayısı" followed by <strong>19</strong>
320
+ count_match = re.search(
321
+ r'Kurum\s*Sayısı.*?<strong[^>]*>\s*(\d+)\s*</strong>',
322
+ html,
323
+ re.IGNORECASE | re.DOTALL,
324
+ )
325
+ if count_match:
326
+ result["numberOfAnalysts"] = int(count_match.group(1))
327
+
328
+ # Calculate median from low and high if available
329
+ if result["low"] is not None and result["high"] is not None:
330
+ result["median"] = round((result["low"] + result["high"]) / 2, 2)
331
+
332
+ return result
333
+
334
+ except Exception:
335
+ return result
336
+
337
+ def _parse_number(self, text: str) -> float | None:
338
+ """
339
+ Parse a Turkish-formatted number.
340
+
341
+ Args:
342
+ text: Number string (e.g., "1.234,56" or "1234.56").
343
+
344
+ Returns:
345
+ Float value or None.
346
+ """
347
+ if not text:
348
+ return None
349
+
350
+ try:
351
+ # Remove spaces
352
+ text = text.strip()
353
+
354
+ # Handle Turkish format: 1.234,56 -> 1234.56
355
+ if "," in text and "." in text:
356
+ # Turkish format: dots are thousands, comma is decimal
357
+ text = text.replace(".", "").replace(",", ".")
358
+ elif "," in text:
359
+ # Comma might be decimal separator
360
+ text = text.replace(",", ".")
361
+
362
+ return float(text)
363
+ except (ValueError, TypeError):
364
+ return None
365
+
366
+
367
+ # Singleton
368
+ _provider: HedeFiyatProvider | None = None
369
+
370
+
371
+ def get_hedeffiyat_provider() -> HedeFiyatProvider:
372
+ """Get singleton provider instance."""
373
+ global _provider
374
+ if _provider is None:
375
+ _provider = HedeFiyatProvider()
376
+ return _provider
@@ -0,0 +1,247 @@
1
+ """ISIN (International Securities Identification Number) provider."""
2
+
3
+ import re
4
+ import time
5
+
6
+ from borsapy._providers.base import BaseProvider
7
+ from borsapy._providers.kap import get_kap_provider
8
+
9
+
10
+ class ISINProvider(BaseProvider):
11
+ """
12
+ Provider for ISIN codes from isinturkiye.com.tr.
13
+
14
+ ISIN codes are unique 12-character identifiers for securities,
15
+ standardized by ISO 6166.
16
+
17
+ Uses a 3-step lookup:
18
+ 1. Get company name from KAP (ticker → company name)
19
+ 2. Find ihracKod by fuzzy matching in ISIN Turkey (company name → ihracKod)
20
+ 3. Get ISIN from ihracKod (ihracKod → ISIN)
21
+
22
+ Example: THYAO → "TÜRK HAVA YOLLARI A.O." → THYA → TRATHYAO91M5
23
+ """
24
+
25
+ ISIN_API_URL = "https://www.isinturkiye.com.tr/v17/tvs/isin/portal/bff/tvs/isin/portal/public/isinListele"
26
+ COMPANY_LIST_URL = "https://www.isinturkiye.com.tr/v17/tvs/isin/portal/bff/tvs/isin/portal/public/isinSirketListe"
27
+ CACHE_DURATION = 86400 * 7 # 7 days (ISIN codes rarely change)
28
+ COMPANY_CACHE_DURATION = 86400 # 24 hours for company list
29
+
30
+ def __init__(self):
31
+ super().__init__()
32
+ self._isin_companies: list[dict] | None = None
33
+ self._isin_companies_time: float = 0
34
+
35
+ def get_isin(self, symbol: str) -> str | None:
36
+ """
37
+ Get ISIN code for a stock symbol.
38
+
39
+ Args:
40
+ symbol: Stock symbol (e.g., "THYAO", "GARAN").
41
+
42
+ Returns:
43
+ ISIN code string (e.g., "TRATHYAO91M5") or None if not found.
44
+ """
45
+ symbol = symbol.upper().replace(".IS", "").replace(".E", "")
46
+
47
+ # Check cache first
48
+ cache_key = f"isin_{symbol}"
49
+ cached = self._cache_get(cache_key)
50
+ if cached is not None:
51
+ return cached
52
+
53
+ try:
54
+ # Step 1: Get company name from KAP
55
+ company_name = self._get_company_name(symbol)
56
+ if not company_name:
57
+ return None
58
+
59
+ # Step 2: Find ihracKod by fuzzy matching
60
+ ihrac_kod = self._find_ihrac_kod(company_name)
61
+ if not ihrac_kod:
62
+ return None
63
+
64
+ # Step 3: Get ISIN from ihracKod
65
+ isin = self._get_isin_from_ihrac(ihrac_kod, symbol)
66
+ if isin:
67
+ self._cache_set(cache_key, isin, self.CACHE_DURATION)
68
+ return isin
69
+
70
+ return None
71
+
72
+ except Exception:
73
+ return None
74
+
75
+ def _get_company_name(self, symbol: str) -> str | None:
76
+ """
77
+ Get company name from KAP for a stock symbol.
78
+
79
+ Args:
80
+ symbol: Stock symbol.
81
+
82
+ Returns:
83
+ Company name or None.
84
+ """
85
+ try:
86
+ kap = get_kap_provider()
87
+ companies_df = kap.get_companies()
88
+ result = companies_df[companies_df["ticker"] == symbol.upper()]
89
+ if not result.empty:
90
+ return result.iloc[0]["name"]
91
+ except Exception:
92
+ pass
93
+ return None
94
+
95
+ def _get_isin_companies(self) -> list[dict]:
96
+ """
97
+ Get and cache the ISIN Turkey company list.
98
+
99
+ Returns:
100
+ List of company dictionaries with srkKod and srkAd.
101
+ """
102
+ current_time = time.time()
103
+
104
+ if (
105
+ self._isin_companies is not None
106
+ and (current_time - self._isin_companies_time) < self.COMPANY_CACHE_DURATION
107
+ ):
108
+ return self._isin_companies
109
+
110
+ try:
111
+ headers = {
112
+ "Accept": "application/json",
113
+ "Content-Type": "application/json",
114
+ "Origin": "https://www.isinturkiye.com.tr",
115
+ "Referer": "https://www.isinturkiye.com.tr/v17/tvs/isin/portal/bff/index.html",
116
+ }
117
+
118
+ response = self._client.post(
119
+ self.COMPANY_LIST_URL,
120
+ headers=headers,
121
+ timeout=30,
122
+ )
123
+ response.raise_for_status()
124
+ data = response.json()
125
+
126
+ self._isin_companies = data.get("resultList", [])
127
+ self._isin_companies_time = current_time
128
+ return self._isin_companies
129
+
130
+ except Exception:
131
+ return []
132
+
133
+ def _normalize_text(self, text: str) -> str:
134
+ """Normalize Turkish text for comparison."""
135
+ text = text.upper()
136
+ tr_map = {"İ": "I", "Ş": "S", "Ğ": "G", "Ü": "U", "Ö": "O", "Ç": "C"}
137
+ for k, v in tr_map.items():
138
+ text = text.replace(k, v)
139
+ return re.sub(r"[.,\-'\"\s]+", " ", text).strip()
140
+
141
+ def _extract_keywords(self, text: str) -> set[str]:
142
+ """Extract meaningful keywords from company name."""
143
+ text = self._normalize_text(text)
144
+ stopwords = {
145
+ "VE", "A", "AS", "AO", "TAS", "ANONIM", "SIRKETI", "SIRKET",
146
+ "TURKIYE", "TURK", "HOLDING", "SANAYI", "TICARET",
147
+ }
148
+ return {w for w in text.split() if w not in stopwords and len(w) > 2}
149
+
150
+ def _find_ihrac_kod(self, company_name: str) -> str | None:
151
+ """
152
+ Find ihracKod by fuzzy matching company name in ISIN Turkey.
153
+
154
+ Args:
155
+ company_name: Company name from KAP.
156
+
157
+ Returns:
158
+ ihracKod or None.
159
+ """
160
+ companies = self._get_isin_companies()
161
+ if not companies:
162
+ return None
163
+
164
+ company_keywords = self._extract_keywords(company_name)
165
+ if not company_keywords:
166
+ return None
167
+
168
+ best_match = None
169
+ best_score = 0
170
+
171
+ for c in companies:
172
+ srk_ad = c.get("srkAd", "")
173
+ # Extract company name part (after "CODE - ")
174
+ srk_name = srk_ad.split(" - ", 1)[1] if " - " in srk_ad else srk_ad
175
+ srk_keywords = self._extract_keywords(srk_name)
176
+
177
+ if srk_keywords:
178
+ common = company_keywords.intersection(srk_keywords)
179
+ score = len(common) / max(len(company_keywords), len(srk_keywords))
180
+
181
+ if score > best_score:
182
+ best_score = score
183
+ best_match = c.get("srkKod")
184
+
185
+ # Return if score is good enough (>0.35)
186
+ return best_match if best_score > 0.35 else None
187
+
188
+ def _get_isin_from_ihrac(self, ihrac_kod: str, symbol: str) -> str | None:
189
+ """
190
+ Get ISIN code from ihracKod.
191
+
192
+ Args:
193
+ ihrac_kod: Issuer code (e.g., "THYA").
194
+ symbol: Stock symbol to match (e.g., "THYAO").
195
+
196
+ Returns:
197
+ ISIN code or None.
198
+ """
199
+ try:
200
+ headers = {
201
+ "Accept": "application/json",
202
+ "Content-Type": "application/json",
203
+ "Origin": "https://www.isinturkiye.com.tr",
204
+ "Referer": "https://www.isinturkiye.com.tr/v17/tvs/isin/portal/bff/index.html",
205
+ }
206
+
207
+ payload = {
208
+ "isinKod": "",
209
+ "ihracKod": ihrac_kod,
210
+ "kategori": "",
211
+ "menkulTurKod": "",
212
+ }
213
+
214
+ response = self._client.post(
215
+ self.ISIN_API_URL,
216
+ json=payload,
217
+ headers=headers,
218
+ timeout=15,
219
+ )
220
+ response.raise_for_status()
221
+ data = response.json()
222
+
223
+ # Find matching stock (PAY type with matching borsaKodu)
224
+ for item in data.get("resultList", []):
225
+ borsa_kodu = item.get("borsaKodu", "").split(" - ")[0].strip()
226
+ menkul_tur = item.get("menkulTur", "")
227
+ isin = item.get("isinKod", "")
228
+
229
+ if borsa_kodu == symbol and ("PAY" in menkul_tur or "Hisse" in menkul_tur):
230
+ return isin
231
+
232
+ return None
233
+
234
+ except Exception:
235
+ return None
236
+
237
+
238
+ # Singleton
239
+ _provider: ISINProvider | None = None
240
+
241
+
242
+ def get_isin_provider() -> ISINProvider:
243
+ """Get singleton provider instance."""
244
+ global _provider
245
+ if _provider is None:
246
+ _provider = ISINProvider()
247
+ return _provider