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.
- borsapy/__init__.py +134 -0
- borsapy/_models/__init__.py +1 -0
- borsapy/_providers/__init__.py +5 -0
- borsapy/_providers/base.py +94 -0
- borsapy/_providers/bist_index.py +150 -0
- borsapy/_providers/btcturk.py +230 -0
- borsapy/_providers/canlidoviz.py +773 -0
- borsapy/_providers/dovizcom.py +869 -0
- borsapy/_providers/dovizcom_calendar.py +276 -0
- borsapy/_providers/dovizcom_tahvil.py +172 -0
- borsapy/_providers/hedeffiyat.py +376 -0
- borsapy/_providers/isin.py +247 -0
- borsapy/_providers/isyatirim.py +943 -0
- borsapy/_providers/isyatirim_screener.py +468 -0
- borsapy/_providers/kap.py +534 -0
- borsapy/_providers/paratic.py +278 -0
- borsapy/_providers/tcmb.py +317 -0
- borsapy/_providers/tefas.py +802 -0
- borsapy/_providers/viop.py +204 -0
- borsapy/bond.py +162 -0
- borsapy/cache.py +86 -0
- borsapy/calendar.py +272 -0
- borsapy/crypto.py +153 -0
- borsapy/exceptions.py +64 -0
- borsapy/fund.py +471 -0
- borsapy/fx.py +388 -0
- borsapy/index.py +285 -0
- borsapy/inflation.py +166 -0
- borsapy/market.py +53 -0
- borsapy/multi.py +227 -0
- borsapy/screener.py +365 -0
- borsapy/ticker.py +1196 -0
- borsapy/viop.py +162 -0
- borsapy-0.4.0.dist-info/METADATA +969 -0
- borsapy-0.4.0.dist-info/RECORD +37 -0
- borsapy-0.4.0.dist-info/WHEEL +4 -0
- borsapy-0.4.0.dist-info/licenses/LICENSE +190 -0
|
@@ -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
|