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,278 @@
|
|
|
1
|
+
"""Paratic provider for historical OHLCV data."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from borsapy._providers.base import BaseProvider
|
|
9
|
+
from borsapy.cache import TTL
|
|
10
|
+
from borsapy.exceptions import APIError, DataNotAvailableError, TickerNotFoundError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ParaticProvider(BaseProvider):
|
|
14
|
+
"""
|
|
15
|
+
Provider for historical OHLCV data from Paratic.
|
|
16
|
+
|
|
17
|
+
API: https://piyasa.paratic.com/API/historical.php
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
BASE_URL = "https://piyasa.paratic.com/API/historical.php"
|
|
21
|
+
|
|
22
|
+
# Required headers for API access
|
|
23
|
+
HEADERS = {
|
|
24
|
+
"Referer": "https://piyasa.paratic.com/",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Period mapping (yfinance-style to days)
|
|
28
|
+
PERIOD_MAP = {
|
|
29
|
+
"1d": 1,
|
|
30
|
+
"5d": 5,
|
|
31
|
+
"1mo": 30,
|
|
32
|
+
"3mo": 90,
|
|
33
|
+
"6mo": 180,
|
|
34
|
+
"1y": 365,
|
|
35
|
+
"2y": 730,
|
|
36
|
+
"5y": 1825,
|
|
37
|
+
"10y": 3650,
|
|
38
|
+
"ytd": None, # Special handling
|
|
39
|
+
"max": 3650, # 10 years max
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Interval mapping (minutes)
|
|
43
|
+
INTERVAL_MAP = {
|
|
44
|
+
"1m": 1,
|
|
45
|
+
"3m": 3,
|
|
46
|
+
"5m": 5,
|
|
47
|
+
"15m": 15,
|
|
48
|
+
"30m": 30,
|
|
49
|
+
"45m": 45,
|
|
50
|
+
"1h": 60,
|
|
51
|
+
"1d": 1440,
|
|
52
|
+
"1wk": 10080,
|
|
53
|
+
"1mo": 43200,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def get_quote(self, symbol: str) -> dict[str, Any]:
|
|
57
|
+
"""
|
|
58
|
+
Get current quote for a symbol.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
symbol: Stock symbol (e.g., "THYAO", "GARAN").
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dictionary with current market data.
|
|
65
|
+
"""
|
|
66
|
+
symbol = symbol.upper().replace(".IS", "").replace(".E", "")
|
|
67
|
+
|
|
68
|
+
cache_key = f"paratic:quote:{symbol}"
|
|
69
|
+
cached = self._cache_get(cache_key)
|
|
70
|
+
if cached is not None:
|
|
71
|
+
return cached
|
|
72
|
+
|
|
73
|
+
# Fetch latest data point
|
|
74
|
+
end_dt = datetime.now()
|
|
75
|
+
params = {
|
|
76
|
+
"a": "d",
|
|
77
|
+
"c": symbol,
|
|
78
|
+
"p": 1440, # Daily
|
|
79
|
+
"from": "",
|
|
80
|
+
"at": end_dt.strftime("%Y%m%d%H%M%S"),
|
|
81
|
+
"group": "f",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
response = self._get(self.BASE_URL, params=params, headers=self.HEADERS)
|
|
86
|
+
data = response.json()
|
|
87
|
+
except Exception as e:
|
|
88
|
+
raise APIError(f"Failed to fetch quote for {symbol}: {e}") from e
|
|
89
|
+
|
|
90
|
+
if not data:
|
|
91
|
+
raise TickerNotFoundError(symbol)
|
|
92
|
+
|
|
93
|
+
# Get the latest data point
|
|
94
|
+
latest = data[-1] if data else None
|
|
95
|
+
if not latest:
|
|
96
|
+
raise TickerNotFoundError(symbol)
|
|
97
|
+
|
|
98
|
+
# Get previous day for change calculation
|
|
99
|
+
prev = data[-2] if len(data) > 1 else None
|
|
100
|
+
prev_close = float(prev.get("c", 0)) if prev else 0
|
|
101
|
+
|
|
102
|
+
last = float(latest.get("c", 0))
|
|
103
|
+
change = last - prev_close if prev_close else 0
|
|
104
|
+
change_pct = (change / prev_close * 100) if prev_close else 0
|
|
105
|
+
|
|
106
|
+
# TL bazında hacim (amount)
|
|
107
|
+
amount = float(latest.get("a", 0))
|
|
108
|
+
|
|
109
|
+
# Lot bazında hacim: TL hacminden hesapla (Paratic'in v değeri hatalı)
|
|
110
|
+
volume = int(amount / last) if last > 0 else 0
|
|
111
|
+
|
|
112
|
+
result = {
|
|
113
|
+
"symbol": symbol,
|
|
114
|
+
"last": last,
|
|
115
|
+
"open": float(latest.get("o", 0)),
|
|
116
|
+
"high": float(latest.get("h", 0)),
|
|
117
|
+
"low": float(latest.get("l", 0)),
|
|
118
|
+
"close": prev_close,
|
|
119
|
+
"volume": volume, # Lot bazında hacim (hesaplanmış)
|
|
120
|
+
"amount": amount, # TL bazında hacim
|
|
121
|
+
"change": round(change, 2),
|
|
122
|
+
"change_percent": round(change_pct, 2),
|
|
123
|
+
"update_time": datetime.fromtimestamp(latest.get("d", 0) / 1000),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
self._cache_set(cache_key, result, TTL.REALTIME_PRICE)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def get_history(
|
|
130
|
+
self,
|
|
131
|
+
symbol: str,
|
|
132
|
+
period: str = "1mo",
|
|
133
|
+
interval: str = "1d",
|
|
134
|
+
start: datetime | None = None,
|
|
135
|
+
end: datetime | None = None,
|
|
136
|
+
) -> pd.DataFrame:
|
|
137
|
+
"""
|
|
138
|
+
Get historical OHLCV data for a symbol.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
symbol: Stock symbol (e.g., "THYAO", "GARAN").
|
|
142
|
+
period: Data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max).
|
|
143
|
+
interval: Data interval (1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo).
|
|
144
|
+
start: Start date (overrides period if provided).
|
|
145
|
+
end: End date (defaults to now).
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
DataFrame with columns: Open, High, Low, Close, Volume.
|
|
149
|
+
"""
|
|
150
|
+
# Normalize symbol
|
|
151
|
+
symbol = symbol.upper().replace(".IS", "").replace(".E", "")
|
|
152
|
+
|
|
153
|
+
# Cache key
|
|
154
|
+
cache_key = f"paratic:history:{symbol}:{period}:{interval}"
|
|
155
|
+
if start:
|
|
156
|
+
cache_key += f":{start.isoformat()}"
|
|
157
|
+
if end:
|
|
158
|
+
cache_key += f":{end.isoformat()}"
|
|
159
|
+
|
|
160
|
+
cached = self._cache_get(cache_key)
|
|
161
|
+
if cached is not None:
|
|
162
|
+
return cached
|
|
163
|
+
|
|
164
|
+
# Calculate date range
|
|
165
|
+
end_dt = end or datetime.now()
|
|
166
|
+
if start:
|
|
167
|
+
start_dt = start
|
|
168
|
+
else:
|
|
169
|
+
days = self._get_period_days(period)
|
|
170
|
+
start_dt = end_dt - timedelta(days=days)
|
|
171
|
+
|
|
172
|
+
# Get interval in minutes
|
|
173
|
+
interval_minutes = self.INTERVAL_MAP.get(interval, 1440)
|
|
174
|
+
|
|
175
|
+
# Build API params
|
|
176
|
+
params = {
|
|
177
|
+
"a": "d", # data type
|
|
178
|
+
"c": symbol,
|
|
179
|
+
"p": interval_minutes,
|
|
180
|
+
"from": "", # Start from beginning
|
|
181
|
+
"at": end_dt.strftime("%Y%m%d%H%M%S"),
|
|
182
|
+
"group": "f",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
response = self._get(self.BASE_URL, params=params, headers=self.HEADERS)
|
|
187
|
+
data = response.json()
|
|
188
|
+
except Exception as e:
|
|
189
|
+
raise APIError(f"Failed to fetch data for {symbol}: {e}") from e
|
|
190
|
+
|
|
191
|
+
if not data:
|
|
192
|
+
raise DataNotAvailableError(f"No data available for {symbol}")
|
|
193
|
+
|
|
194
|
+
# Parse response
|
|
195
|
+
df = self._parse_response(data, start_dt, end_dt)
|
|
196
|
+
|
|
197
|
+
# Cache result
|
|
198
|
+
self._cache_set(cache_key, df, TTL.OHLCV_HISTORY)
|
|
199
|
+
|
|
200
|
+
return df
|
|
201
|
+
|
|
202
|
+
def _get_period_days(self, period: str) -> int:
|
|
203
|
+
"""Convert period string to number of days."""
|
|
204
|
+
if period == "ytd":
|
|
205
|
+
today = datetime.now()
|
|
206
|
+
year_start = datetime(today.year, 1, 1)
|
|
207
|
+
return (today - year_start).days
|
|
208
|
+
|
|
209
|
+
days = self.PERIOD_MAP.get(period)
|
|
210
|
+
if days is None:
|
|
211
|
+
# Default to 30 days
|
|
212
|
+
return 30
|
|
213
|
+
return days
|
|
214
|
+
|
|
215
|
+
def _parse_response(
|
|
216
|
+
self,
|
|
217
|
+
data: list[dict[str, Any]],
|
|
218
|
+
start_dt: datetime,
|
|
219
|
+
end_dt: datetime,
|
|
220
|
+
) -> pd.DataFrame:
|
|
221
|
+
"""
|
|
222
|
+
Parse API response into DataFrame.
|
|
223
|
+
|
|
224
|
+
Response format:
|
|
225
|
+
[
|
|
226
|
+
{"d": timestamp_ms, "o": open, "h": high, "l": low, "c": close, "v": volume, ...},
|
|
227
|
+
...
|
|
228
|
+
]
|
|
229
|
+
"""
|
|
230
|
+
if not data:
|
|
231
|
+
return pd.DataFrame(columns=["Open", "High", "Low", "Close", "Volume"])
|
|
232
|
+
|
|
233
|
+
records = []
|
|
234
|
+
for item in data:
|
|
235
|
+
try:
|
|
236
|
+
timestamp_ms = item.get("d")
|
|
237
|
+
if timestamp_ms is None:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
dt = datetime.fromtimestamp(timestamp_ms / 1000)
|
|
241
|
+
|
|
242
|
+
# Filter by date range
|
|
243
|
+
if dt < start_dt or dt > end_dt:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
records.append(
|
|
247
|
+
{
|
|
248
|
+
"Date": dt,
|
|
249
|
+
"Open": float(item.get("o", 0)),
|
|
250
|
+
"High": float(item.get("h", 0)),
|
|
251
|
+
"Low": float(item.get("l", 0)),
|
|
252
|
+
"Close": float(item.get("c", 0)),
|
|
253
|
+
"Volume": int(item.get("v", 0)),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
except (TypeError, ValueError):
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
if not records:
|
|
260
|
+
return pd.DataFrame(columns=["Open", "High", "Low", "Close", "Volume"])
|
|
261
|
+
|
|
262
|
+
df = pd.DataFrame(records)
|
|
263
|
+
df.set_index("Date", inplace=True)
|
|
264
|
+
df.sort_index(inplace=True)
|
|
265
|
+
|
|
266
|
+
return df
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# Singleton instance
|
|
270
|
+
_provider: ParaticProvider | None = None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def get_paratic_provider() -> ParaticProvider:
|
|
274
|
+
"""Get the singleton Paratic provider instance."""
|
|
275
|
+
global _provider
|
|
276
|
+
if _provider is None:
|
|
277
|
+
_provider = ParaticProvider()
|
|
278
|
+
return _provider
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""TCMB provider for inflation data."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from bs4 import BeautifulSoup
|
|
9
|
+
|
|
10
|
+
from borsapy._providers.base import BaseProvider
|
|
11
|
+
from borsapy.cache import TTL
|
|
12
|
+
from borsapy.exceptions import APIError, DataNotAvailableError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TCMBProvider(BaseProvider):
|
|
16
|
+
"""
|
|
17
|
+
Provider for inflation data from TCMB (Turkish Central Bank).
|
|
18
|
+
|
|
19
|
+
Provides:
|
|
20
|
+
- TÜFE (CPI) inflation data
|
|
21
|
+
- ÜFE (PPI) inflation data
|
|
22
|
+
- Inflation calculation between dates
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
BASE_URL = "https://www.tcmb.gov.tr"
|
|
26
|
+
CALC_API_URL = "https://appg.tcmb.gov.tr/KIMENFH/enflasyon/hesapla"
|
|
27
|
+
|
|
28
|
+
INFLATION_PATHS = {
|
|
29
|
+
"tufe": "/wps/wcm/connect/tr/tcmb+tr/main+menu/istatistikler/enflasyon+verileri",
|
|
30
|
+
"ufe": "/wps/wcm/connect/TR/TCMB+TR/Main+Menu/Istatistikler/Enflasyon+Verileri/Uretici+Fiyatlari",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def calculate_inflation(
|
|
34
|
+
self,
|
|
35
|
+
start_year: int,
|
|
36
|
+
start_month: int,
|
|
37
|
+
end_year: int,
|
|
38
|
+
end_month: int,
|
|
39
|
+
basket_value: float = 100.0,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""
|
|
42
|
+
Calculate inflation between two dates using TCMB API.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
start_year: Starting year (e.g., 2020)
|
|
46
|
+
start_month: Starting month (1-12)
|
|
47
|
+
end_year: Ending year (e.g., 2024)
|
|
48
|
+
end_month: Ending month (1-12)
|
|
49
|
+
basket_value: Initial value in TRY (default: 100.0)
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Dictionary with:
|
|
53
|
+
- start_date: Start date string
|
|
54
|
+
- end_date: End date string
|
|
55
|
+
- initial_value: Initial basket value
|
|
56
|
+
- final_value: Final value after inflation
|
|
57
|
+
- total_years: Total years elapsed
|
|
58
|
+
- total_months: Total months elapsed
|
|
59
|
+
- total_change: Total percentage change
|
|
60
|
+
- avg_yearly_inflation: Average yearly inflation
|
|
61
|
+
- start_cpi: CPI at start date
|
|
62
|
+
- end_cpi: CPI at end date
|
|
63
|
+
"""
|
|
64
|
+
# Validate inputs
|
|
65
|
+
now = datetime.now()
|
|
66
|
+
if not (1982 <= start_year <= now.year):
|
|
67
|
+
raise ValueError(f"Start year must be between 1982 and {now.year}")
|
|
68
|
+
if not (1982 <= end_year <= now.year):
|
|
69
|
+
raise ValueError(f"End year must be between 1982 and {now.year}")
|
|
70
|
+
if not (1 <= start_month <= 12) or not (1 <= end_month <= 12):
|
|
71
|
+
raise ValueError("Month must be between 1 and 12")
|
|
72
|
+
if basket_value <= 0:
|
|
73
|
+
raise ValueError("Basket value must be positive")
|
|
74
|
+
|
|
75
|
+
start_date = datetime(start_year, start_month, 1)
|
|
76
|
+
end_date = datetime(end_year, end_month, 1)
|
|
77
|
+
if start_date >= end_date:
|
|
78
|
+
raise ValueError("Start date must be before end date")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
headers = {
|
|
82
|
+
"Accept": "*/*",
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"Origin": "https://herkesicin.tcmb.gov.tr",
|
|
85
|
+
"Referer": "https://herkesicin.tcmb.gov.tr/",
|
|
86
|
+
"User-Agent": self.DEFAULT_HEADERS["User-Agent"],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
payload = {
|
|
90
|
+
"baslangicYil": str(start_year),
|
|
91
|
+
"baslangicAy": str(start_month),
|
|
92
|
+
"bitisYil": str(end_year),
|
|
93
|
+
"bitisAy": str(end_month),
|
|
94
|
+
"malSepeti": str(basket_value),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
response = self._client.post(
|
|
98
|
+
self.CALC_API_URL, headers=headers, json=payload, timeout=30.0
|
|
99
|
+
)
|
|
100
|
+
response.raise_for_status()
|
|
101
|
+
data = response.json()
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"start_date": f"{start_year}-{start_month:02d}",
|
|
105
|
+
"end_date": f"{end_year}-{end_month:02d}",
|
|
106
|
+
"initial_value": basket_value,
|
|
107
|
+
"final_value": self._parse_float(data.get("yeniSepetDeger", "")),
|
|
108
|
+
"total_years": int(data.get("toplamYil", 0)),
|
|
109
|
+
"total_months": int(data.get("toplamAy", 0)),
|
|
110
|
+
"total_change": self._parse_float(data.get("toplamDegisim", "")),
|
|
111
|
+
"avg_yearly_inflation": self._parse_float(data.get("ortalamaYillikEnflasyon", "")),
|
|
112
|
+
"start_cpi": self._parse_float(data.get("ilkYilTufe", "")),
|
|
113
|
+
"end_cpi": self._parse_float(data.get("sonYilTufe", "")),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
except Exception as e:
|
|
117
|
+
raise APIError(f"Failed to calculate inflation: {e}") from e
|
|
118
|
+
|
|
119
|
+
def get_data(
|
|
120
|
+
self,
|
|
121
|
+
inflation_type: str = "tufe",
|
|
122
|
+
start_date: str | None = None,
|
|
123
|
+
end_date: str | None = None,
|
|
124
|
+
limit: int | None = None,
|
|
125
|
+
) -> pd.DataFrame:
|
|
126
|
+
"""
|
|
127
|
+
Get inflation data from TCMB website.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
inflation_type: 'tufe' (CPI) or 'ufe' (PPI)
|
|
131
|
+
start_date: Start date in YYYY-MM-DD format
|
|
132
|
+
end_date: End date in YYYY-MM-DD format
|
|
133
|
+
limit: Maximum number of records
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
DataFrame with columns: Date, YearMonth, YearlyInflation, MonthlyInflation
|
|
137
|
+
"""
|
|
138
|
+
if inflation_type not in self.INFLATION_PATHS:
|
|
139
|
+
raise ValueError(f"Invalid type: {inflation_type}. Use 'tufe' or 'ufe'")
|
|
140
|
+
|
|
141
|
+
cache_key = f"tcmb:data:{inflation_type}"
|
|
142
|
+
cached = self._cache_get(cache_key)
|
|
143
|
+
|
|
144
|
+
if cached is None:
|
|
145
|
+
try:
|
|
146
|
+
url = self.BASE_URL + self.INFLATION_PATHS[inflation_type]
|
|
147
|
+
headers = {
|
|
148
|
+
"Accept": "text/html,application/xhtml+xml",
|
|
149
|
+
"Accept-Language": "tr-TR,tr;q=0.9",
|
|
150
|
+
"User-Agent": self.DEFAULT_HEADERS["User-Agent"],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
response = self._client.get(url, headers=headers, timeout=30.0)
|
|
154
|
+
response.raise_for_status()
|
|
155
|
+
|
|
156
|
+
cached = self._parse_inflation_table(response.text)
|
|
157
|
+
self._cache_set(cache_key, cached, TTL.FX_RATES)
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
raise APIError(f"Failed to fetch inflation data: {e}") from e
|
|
161
|
+
|
|
162
|
+
df = pd.DataFrame(cached)
|
|
163
|
+
if df.empty:
|
|
164
|
+
raise DataNotAvailableError(f"No data available for {inflation_type}")
|
|
165
|
+
|
|
166
|
+
# Apply filters
|
|
167
|
+
if start_date:
|
|
168
|
+
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
|
169
|
+
df = df[df["Date"] >= start_dt]
|
|
170
|
+
|
|
171
|
+
if end_date:
|
|
172
|
+
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
|
173
|
+
df = df[df["Date"] <= end_dt]
|
|
174
|
+
|
|
175
|
+
if limit and limit > 0:
|
|
176
|
+
df = df.head(limit)
|
|
177
|
+
|
|
178
|
+
df.set_index("Date", inplace=True)
|
|
179
|
+
return df
|
|
180
|
+
|
|
181
|
+
def get_latest(self, inflation_type: str = "tufe") -> dict[str, Any]:
|
|
182
|
+
"""
|
|
183
|
+
Get the latest inflation data point.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
inflation_type: 'tufe' (CPI) or 'ufe' (PPI)
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Dictionary with latest inflation data.
|
|
190
|
+
"""
|
|
191
|
+
df = self.get_data(inflation_type, limit=1)
|
|
192
|
+
if df.empty:
|
|
193
|
+
raise DataNotAvailableError(f"No data available for {inflation_type}")
|
|
194
|
+
|
|
195
|
+
row = df.iloc[0]
|
|
196
|
+
return {
|
|
197
|
+
"date": df.index[0].strftime("%Y-%m-%d"),
|
|
198
|
+
"year_month": row["YearMonth"],
|
|
199
|
+
"yearly_inflation": row["YearlyInflation"],
|
|
200
|
+
"monthly_inflation": row["MonthlyInflation"],
|
|
201
|
+
"type": inflation_type.upper(),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def _parse_inflation_table(self, html_content: str) -> list[dict[str, Any]]:
|
|
205
|
+
"""Parse HTML table and extract inflation data."""
|
|
206
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
207
|
+
tables = soup.find_all("table")
|
|
208
|
+
|
|
209
|
+
inflation_data = []
|
|
210
|
+
|
|
211
|
+
for table in tables:
|
|
212
|
+
headers_row = table.find("tr")
|
|
213
|
+
if not headers_row:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
headers = [th.get_text(strip=True) for th in headers_row.find_all(["th", "td"])]
|
|
217
|
+
header_text = " ".join(headers).lower()
|
|
218
|
+
|
|
219
|
+
if not any(kw in header_text for kw in ["tüfe", "üfe", "enflasyon", "yıllık", "%"]):
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
rows = table.find_all("tr")[1:]
|
|
223
|
+
|
|
224
|
+
for row in rows:
|
|
225
|
+
cells = row.find_all(["td", "th"])
|
|
226
|
+
cell_texts = [cell.get_text(strip=True) for cell in cells]
|
|
227
|
+
|
|
228
|
+
if not cell_texts or not cell_texts[0] or "ÜFE" in cell_texts[0]:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
if len(cell_texts) >= 5: # ÜFE format
|
|
233
|
+
date_str = cell_texts[0]
|
|
234
|
+
yearly_str = cell_texts[2]
|
|
235
|
+
monthly_str = cell_texts[4] if len(cell_texts) > 4 else ""
|
|
236
|
+
elif len(cell_texts) >= 3: # TÜFE format
|
|
237
|
+
date_str = cell_texts[0]
|
|
238
|
+
yearly_str = cell_texts[1]
|
|
239
|
+
monthly_str = cell_texts[2]
|
|
240
|
+
else:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
date_obj = self._parse_date(date_str)
|
|
244
|
+
yearly_pct = self._parse_percentage(yearly_str)
|
|
245
|
+
monthly_pct = self._parse_percentage(monthly_str)
|
|
246
|
+
|
|
247
|
+
if date_obj and yearly_pct is not None:
|
|
248
|
+
inflation_data.append(
|
|
249
|
+
{
|
|
250
|
+
"Date": date_obj,
|
|
251
|
+
"YearMonth": date_str,
|
|
252
|
+
"YearlyInflation": yearly_pct,
|
|
253
|
+
"MonthlyInflation": monthly_pct,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
except Exception:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
# Sort by date (newest first)
|
|
263
|
+
inflation_data.sort(key=lambda x: x["Date"], reverse=True)
|
|
264
|
+
return inflation_data
|
|
265
|
+
|
|
266
|
+
def _parse_date(self, date_str: str) -> datetime | None:
|
|
267
|
+
"""Parse date string from TCMB format (MM-YYYY)."""
|
|
268
|
+
if not date_str:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
date_str = date_str.strip().replace(".", "").replace(",", "")
|
|
272
|
+
match = re.search(r"(\d{1,2})-(\d{4})", date_str)
|
|
273
|
+
|
|
274
|
+
if match:
|
|
275
|
+
month, year = match.groups()
|
|
276
|
+
try:
|
|
277
|
+
return datetime(int(year), int(month), 1)
|
|
278
|
+
except ValueError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def _parse_percentage(self, pct_str: str) -> float | None:
|
|
284
|
+
"""Parse percentage string to float."""
|
|
285
|
+
if not pct_str:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
pct_str = pct_str.strip().replace("%", "").replace(",", ".")
|
|
289
|
+
pct_str = re.sub(r"[^\d\-\.]", "", pct_str)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
return float(pct_str)
|
|
293
|
+
except ValueError:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
def _parse_float(self, value: str) -> float | None:
|
|
297
|
+
"""Parse float from string, handling TCMB API number format."""
|
|
298
|
+
if not value:
|
|
299
|
+
return None
|
|
300
|
+
try:
|
|
301
|
+
# TCMB API format: 444,399.15 (comma=thousands, dot=decimal)
|
|
302
|
+
value = str(value).replace(",", "")
|
|
303
|
+
return float(value)
|
|
304
|
+
except (ValueError, TypeError):
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# Singleton
|
|
309
|
+
_provider: TCMBProvider | None = None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_tcmb_provider() -> TCMBProvider:
|
|
313
|
+
"""Get singleton provider instance."""
|
|
314
|
+
global _provider
|
|
315
|
+
if _provider is None:
|
|
316
|
+
_provider = TCMBProvider()
|
|
317
|
+
return _provider
|