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,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