borsapy

borsapy - Turkish Financial Markets Data Library

A yfinance-like API for BIST stocks, forex, crypto, funds, and economic data.

Examples:

import borsapy as bp

# Get stock data
>>> stock = bp.Ticker("THYAO")
>>> stock.info  # Real-time quote
>>> stock.history(period="1mo")  # OHLCV data
>>> stock.balance_sheet  # Financial statements

# Get forex/commodity data
>>> usd = bp.FX("USD")
>>> usd.current  # Current rate
>>> usd.history(period="1mo")  # Historical data
>>> usd.bank_rates  # Bank exchange rates
>>> usd.bank_rate("akbank")  # Single bank rate
>>> bp.banks()  # List supported banks
>>> gold = bp.FX("gram-altin")

# List all BIST companies
>>> bp.companies()
>>> bp.search_companies("banka")

# Get crypto data
>>> btc = bp.Crypto("BTCTRY")
>>> btc.current  # Current price
>>> btc.history(period="1mo")  # Historical OHLCV
>>> bp.crypto_pairs()  # List available pairs

# Get fund data
>>> fund = bp.Fund("AAK")
>>> fund.info  # Fund details
>>> fund.history(period="1mo")  # Price history

# Get inflation data
>>> inf = bp.Inflation()
>>> inf.latest()  # Latest TÜFE data
>>> inf.calculate(100000, "2020-01", "2024-01")  # Inflation calculation

# Economic calendar
>>> cal = bp.EconomicCalendar()
>>> cal.events(period="1w")  # This week's events
>>> cal.today()  # Today's events
>>> bp.economic_calendar(country="TR", importance="high")

# Government bonds
>>> bp.bonds()  # All bond yields
>>> bond = bp.Bond("10Y")
>>> bond.yield_rate  # Current 10Y yield
>>> bp.risk_free_rate()  # For DCF calculations

# Stock screener
>>> bp.screen_stocks(template="high_dividend")
>>> bp.screen_stocks(market_cap_min=1000, pe_max=15)
>>> screener = bp.Screener()
>>> screener.add_filter("dividend_yield", min=3).run()

# Real-time streaming (low-latency)
>>> stream = bp.TradingViewStream()
>>> stream.connect()
>>> stream.subscribe("THYAO")
>>> quote = stream.get_quote("THYAO")  # Instant (~1ms)
>>> quote['last']  # Last price
>>> stream.disconnect()

# Context manager
>>> with bp.TradingViewStream() as stream:
...     stream.subscribe("THYAO")
...     quote = stream.wait_for_quote("THYAO")
...     print(quote['last'])

# Symbol search
>>> bp.search("banka")  # Search all markets
>>> bp.search_bist("enerji")  # BIST only
>>> bp.search_crypto("BTC")  # Crypto only
>>> bp.search("THYAO", full_info=True)  # Detailed results

# Heikin Ashi charts
>>> df = stock.history(period="1y")
>>> ha_df = bp.calculate_heikin_ashi(df)  # Returns HA_Open, HA_High, HA_Low, HA_Close
>>> ha_df = stock.heikin_ashi(period="1y")  # Convenience method

# Chart streaming (OHLCV candles via WebSocket)
>>> stream = bp.TradingViewStream()
>>> stream.connect()
>>> stream.subscribe_chart("THYAO", "1m")  # 1-minute candles
>>> candle = stream.get_candle("THYAO", "1m")
>>> print(candle['close'])

# Historical replay for backtesting
>>> session = bp.create_replay("THYAO", period="1y", speed=100)
>>> for candle in session.replay():
...     print(f"{candle['timestamp']}: {candle['close']}")

# Backtest engine
>>> def rsi_strategy(candle, position, indicators):
...     if indicators['rsi'] < 30 and position is None:
...         return 'BUY'
...     elif indicators['rsi'] > 70 and position == 'long':
...         return 'SELL'
...     return 'HOLD'
>>> result = bp.backtest("THYAO", rsi_strategy, period="1y", indicators=['rsi'])
>>> print(result.summary())
>>> print(f"Win Rate: {result.win_rate:.1f}%")

# Pine Script streaming indicators
>>> stream = bp.TradingViewStream()
>>> stream.connect()
>>> stream.subscribe_chart("THYAO", "1m")
>>> stream.add_study("THYAO", "1m", "RSI")
>>> stream.add_study("THYAO", "1m", "MACD")
>>> rsi = stream.get_study("THYAO", "1m", "RSI")
>>> print(rsi['value'])
  1"""
  2borsapy - Turkish Financial Markets Data Library
  3
  4A yfinance-like API for BIST stocks, forex, crypto, funds, and economic data.
  5
  6Examples:
  7    >>> import borsapy as bp
  8
  9    # Get stock data
 10    >>> stock = bp.Ticker("THYAO")
 11    >>> stock.info  # Real-time quote
 12    >>> stock.history(period="1mo")  # OHLCV data
 13    >>> stock.balance_sheet  # Financial statements
 14
 15    # Get forex/commodity data
 16    >>> usd = bp.FX("USD")
 17    >>> usd.current  # Current rate
 18    >>> usd.history(period="1mo")  # Historical data
 19    >>> usd.bank_rates  # Bank exchange rates
 20    >>> usd.bank_rate("akbank")  # Single bank rate
 21    >>> bp.banks()  # List supported banks
 22    >>> gold = bp.FX("gram-altin")
 23
 24    # List all BIST companies
 25    >>> bp.companies()
 26    >>> bp.search_companies("banka")
 27
 28    # Get crypto data
 29    >>> btc = bp.Crypto("BTCTRY")
 30    >>> btc.current  # Current price
 31    >>> btc.history(period="1mo")  # Historical OHLCV
 32    >>> bp.crypto_pairs()  # List available pairs
 33
 34    # Get fund data
 35    >>> fund = bp.Fund("AAK")
 36    >>> fund.info  # Fund details
 37    >>> fund.history(period="1mo")  # Price history
 38
 39    # Get inflation data
 40    >>> inf = bp.Inflation()
 41    >>> inf.latest()  # Latest TÜFE data
 42    >>> inf.calculate(100000, "2020-01", "2024-01")  # Inflation calculation
 43
 44    # Economic calendar
 45    >>> cal = bp.EconomicCalendar()
 46    >>> cal.events(period="1w")  # This week's events
 47    >>> cal.today()  # Today's events
 48    >>> bp.economic_calendar(country="TR", importance="high")
 49
 50    # Government bonds
 51    >>> bp.bonds()  # All bond yields
 52    >>> bond = bp.Bond("10Y")
 53    >>> bond.yield_rate  # Current 10Y yield
 54    >>> bp.risk_free_rate()  # For DCF calculations
 55
 56    # Stock screener
 57    >>> bp.screen_stocks(template="high_dividend")
 58    >>> bp.screen_stocks(market_cap_min=1000, pe_max=15)
 59    >>> screener = bp.Screener()
 60    >>> screener.add_filter("dividend_yield", min=3).run()
 61
 62    # Real-time streaming (low-latency)
 63    >>> stream = bp.TradingViewStream()
 64    >>> stream.connect()
 65    >>> stream.subscribe("THYAO")
 66    >>> quote = stream.get_quote("THYAO")  # Instant (~1ms)
 67    >>> quote['last']  # Last price
 68    >>> stream.disconnect()
 69
 70    # Context manager
 71    >>> with bp.TradingViewStream() as stream:
 72    ...     stream.subscribe("THYAO")
 73    ...     quote = stream.wait_for_quote("THYAO")
 74    ...     print(quote['last'])
 75
 76    # Symbol search
 77    >>> bp.search("banka")  # Search all markets
 78    >>> bp.search_bist("enerji")  # BIST only
 79    >>> bp.search_crypto("BTC")  # Crypto only
 80    >>> bp.search("THYAO", full_info=True)  # Detailed results
 81
 82    # Heikin Ashi charts
 83    >>> df = stock.history(period="1y")
 84    >>> ha_df = bp.calculate_heikin_ashi(df)  # Returns HA_Open, HA_High, HA_Low, HA_Close
 85    >>> ha_df = stock.heikin_ashi(period="1y")  # Convenience method
 86
 87    # Chart streaming (OHLCV candles via WebSocket)
 88    >>> stream = bp.TradingViewStream()
 89    >>> stream.connect()
 90    >>> stream.subscribe_chart("THYAO", "1m")  # 1-minute candles
 91    >>> candle = stream.get_candle("THYAO", "1m")
 92    >>> print(candle['close'])
 93
 94    # Historical replay for backtesting
 95    >>> session = bp.create_replay("THYAO", period="1y", speed=100)
 96    >>> for candle in session.replay():
 97    ...     print(f"{candle['timestamp']}: {candle['close']}")
 98
 99    # Backtest engine
100    >>> def rsi_strategy(candle, position, indicators):
101    ...     if indicators['rsi'] < 30 and position is None:
102    ...         return 'BUY'
103    ...     elif indicators['rsi'] > 70 and position == 'long':
104    ...         return 'SELL'
105    ...     return 'HOLD'
106    >>> result = bp.backtest("THYAO", rsi_strategy, period="1y", indicators=['rsi'])
107    >>> print(result.summary())
108    >>> print(f"Win Rate: {result.win_rate:.1f}%")
109
110    # Pine Script streaming indicators
111    >>> stream = bp.TradingViewStream()
112    >>> stream.connect()
113    >>> stream.subscribe_chart("THYAO", "1m")
114    >>> stream.add_study("THYAO", "1m", "RSI")
115    >>> stream.add_study("THYAO", "1m", "MACD")
116    >>> rsi = stream.get_study("THYAO", "1m", "RSI")
117    >>> print(rsi['value'])
118"""
119
120# TradingView authentication for real-time data
121from borsapy._providers.tradingview import (
122    clear_tradingview_auth,
123    get_tradingview_auth,
124    set_tradingview_auth,
125)
126
127# Twitter/X authentication (optional, requires borsapy[twitter])
128from borsapy._providers.twitter import (
129    clear_twitter_auth,
130    get_twitter_auth,
131    set_twitter_auth,
132)
133from borsapy.backtest import Backtest, BacktestResult, Trade, backtest
134from borsapy.bond import Bond, bonds, risk_free_rate
135from borsapy.calendar import EconomicCalendar, economic_calendar
136from borsapy.charts import calculate_heikin_ashi
137from borsapy.crypto import Crypto, crypto_pairs
138from borsapy.eurobond import Eurobond, eurobonds
139from borsapy.evds import (
140    EVDS,
141    EVDSSeries,
142    clear_evds_key,
143    evds_categories,
144    evds_download,
145    evds_search,
146    evds_series,
147    get_evds_key,
148    set_evds_key,
149)
150from borsapy.exceptions import (
151    APIError,
152    AuthenticationError,
153    BorsapyError,
154    DataNotAvailableError,
155    InvalidIntervalError,
156    InvalidPeriodError,
157    RateLimitError,
158    TickerNotFoundError,
159)
160from borsapy.fund import Fund, compare_funds, management_fees, screen_funds, search_funds
161from borsapy.fx import FX, banks, metal_institutions
162from borsapy.index import Index, all_indices, index, indices
163from borsapy.inflation import Inflation
164from borsapy.market import companies, search_companies
165from borsapy.multi import Tickers, download
166from borsapy.portfolio import Portfolio
167from borsapy.replay import ReplaySession, create_replay
168from borsapy.scanner import ScanResult, TechnicalScanner, scan
169from borsapy.screener import Screener, screen_stocks, screener_criteria, sectors, stock_indices
170from borsapy.search import (
171    search,
172    search_bist,
173    search_crypto,
174    search_forex,
175    search_index,
176    search_viop,
177    viop_contracts,
178)
179
180# TradingView streaming for real-time updates
181from borsapy.stream import TradingViewStream, create_stream
182from borsapy.tax import withholding_tax_rate, withholding_tax_table
183from borsapy.tcmb import TCMB, policy_rate
184from borsapy.technical import (
185    TechnicalAnalyzer,
186    add_indicators,
187    calculate_adx,
188    calculate_atr,
189    calculate_bollinger_bands,
190    calculate_dema,
191    calculate_ema,
192    calculate_hhv,
193    calculate_llv,
194    calculate_macd,
195    calculate_mom,
196    calculate_obv,
197    calculate_roc,
198    calculate_rsi,
199    calculate_sma,
200    calculate_stochastic,
201    calculate_supertrend,
202    calculate_tema,
203    calculate_tilson_t3,
204    calculate_vwap,
205    calculate_wma,
206)
207from borsapy.ticker import Ticker
208from borsapy.twitter import search_tweets
209from borsapy.viop import VIOP
210
211__version__ = "0.10.0"
212__author__ = "Said Surucu"
213
214__all__ = [
215    # Main classes
216    "Ticker",
217    "Tickers",
218    "FX",
219    "Crypto",
220    "Fund",
221    "Portfolio",
222    "Index",
223    "Inflation",
224    "VIOP",
225    "Bond",
226    "Eurobond",
227    "TCMB",
228    "EVDS",
229    "EVDSSeries",
230    "EconomicCalendar",
231    "Screener",
232    "TradingViewStream",
233    "ReplaySession",
234    # Market functions
235    "companies",
236    "search_companies",
237    "search",
238    "search_bist",
239    "search_crypto",
240    "search_forex",
241    "search_index",
242    "search_viop",
243    "viop_contracts",
244    "banks",
245    "metal_institutions",
246    "crypto_pairs",
247    "search_funds",
248    "screen_funds",
249    "compare_funds",
250    "management_fees",
251    "download",
252    "index",
253    "indices",
254    "all_indices",
255    # Bond functions
256    "bonds",
257    "risk_free_rate",
258    # Eurobond functions
259    "eurobonds",
260    # TCMB functions
261    "policy_rate",
262    # EVDS (TCMB Elektronik Veri Dağıtım Sistemi)
263    "evds_categories",
264    "evds_search",
265    "evds_series",
266    "evds_download",
267    "set_evds_key",
268    "clear_evds_key",
269    "get_evds_key",
270    # Calendar functions
271    "economic_calendar",
272    # Screener functions
273    "screen_stocks",
274    "screener_criteria",
275    "sectors",
276    "stock_indices",
277    # Technical Scanner
278    "TechnicalScanner",
279    "ScanResult",
280    "scan",
281    # Technical analysis
282    "TechnicalAnalyzer",
283    "add_indicators",
284    "calculate_sma",
285    "calculate_ema",
286    "calculate_rsi",
287    "calculate_macd",
288    "calculate_bollinger_bands",
289    "calculate_atr",
290    "calculate_stochastic",
291    "calculate_obv",
292    "calculate_vwap",
293    "calculate_adx",
294    "calculate_supertrend",
295    "calculate_tilson_t3",
296    # MetaStock indicators
297    "calculate_hhv",
298    "calculate_llv",
299    "calculate_mom",
300    "calculate_roc",
301    "calculate_wma",
302    "calculate_dema",
303    "calculate_tema",
304    # Charts
305    "calculate_heikin_ashi",
306    # Replay
307    "ReplaySession",
308    "create_replay",
309    # Exceptions
310    "BorsapyError",
311    "TickerNotFoundError",
312    "DataNotAvailableError",
313    "APIError",
314    "AuthenticationError",
315    "RateLimitError",
316    "InvalidPeriodError",
317    "InvalidIntervalError",
318    # TradingView authentication (premium)
319    "set_tradingview_auth",
320    "get_tradingview_auth",
321    "clear_tradingview_auth",
322    # TradingView streaming (real-time)
323    "TradingViewStream",
324    "create_stream",
325    # Backtest engine
326    "Backtest",
327    "BacktestResult",
328    "Trade",
329    "backtest",
330    # Tax
331    "withholding_tax_rate",
332    "withholding_tax_table",
333    # Twitter/X (optional, requires borsapy[twitter])
334    "set_twitter_auth",
335    "get_twitter_auth",
336    "clear_twitter_auth",
337    "search_tweets",
338]
class Ticker(borsapy.technical.TechnicalMixin, borsapy.twitter.TwitterMixin):
 523class Ticker(TechnicalMixin, TwitterMixin):
 524    """
 525    A yfinance-like interface for Turkish stock data.
 526
 527    Examples:
 528        >>> import borsapy as bp
 529        >>> stock = bp.Ticker("THYAO")
 530        >>> stock.info
 531        {'symbol': 'THYAO', 'last': 268.5, ...}
 532        >>> stock.history(period="1mo")
 533                         Open    High     Low   Close    Volume
 534        Date
 535        2024-12-01    265.00  268.00  264.00  267.50  12345678
 536        ...
 537    """
 538
 539    def __init__(self, symbol: str):
 540        """
 541        Initialize a Ticker object.
 542
 543        Args:
 544            symbol: Stock symbol (e.g., "THYAO", "GARAN", "ASELS").
 545                    The ".IS" or ".E" suffix is optional and will be removed.
 546        """
 547        self._symbol = symbol.upper().replace(".IS", "").replace(".E", "")
 548        self._tradingview = get_tradingview_provider()
 549        self._isyatirim = None  # Lazy load for financial statements
 550        self._kap = None  # Lazy load for KAP disclosures
 551        self._isin_provider = None  # Lazy load for ISIN lookup
 552        self._hedeffiyat = None  # Lazy load for analyst price targets
 553        self._etf_provider = None  # Lazy load for ETF holders
 554
 555    def _get_tweet_query(self) -> str:
 556        return _build_stock_query(self._symbol)
 557
 558    def _get_isyatirim(self):
 559        """Lazy load İş Yatırım provider for financial statements."""
 560        if self._isyatirim is None:
 561            from borsapy._providers.isyatirim import get_isyatirim_provider
 562
 563            self._isyatirim = get_isyatirim_provider()
 564        return self._isyatirim
 565
 566    def _get_kap(self):
 567        """Lazy load KAP provider for disclosures and calendar."""
 568        if self._kap is None:
 569            from borsapy._providers.kap import get_kap_provider
 570
 571            self._kap = get_kap_provider()
 572        return self._kap
 573
 574    def _get_isin_provider(self):
 575        """Lazy load ISIN provider."""
 576        if self._isin_provider is None:
 577            from borsapy._providers.isin import get_isin_provider
 578
 579            self._isin_provider = get_isin_provider()
 580        return self._isin_provider
 581
 582    def _get_hedeffiyat(self):
 583        """Lazy load hedeffiyat.com.tr provider for analyst price targets."""
 584        if self._hedeffiyat is None:
 585            from borsapy._providers.hedeffiyat import get_hedeffiyat_provider
 586
 587            self._hedeffiyat = get_hedeffiyat_provider()
 588        return self._hedeffiyat
 589
 590    def _get_etf_provider(self):
 591        """Lazy load TradingView ETF provider for ETF holders."""
 592        if self._etf_provider is None:
 593            from borsapy._providers.tradingview_etf import get_tradingview_etf_provider
 594
 595            self._etf_provider = get_tradingview_etf_provider()
 596        return self._etf_provider
 597
 598    @property
 599    def symbol(self) -> str:
 600        """Return the ticker symbol."""
 601        return self._symbol
 602
 603    @property
 604    def fast_info(self) -> FastInfo:
 605        """
 606        Get fast access to common ticker information.
 607
 608        Returns a FastInfo object with quick access to frequently used data:
 609        - currency, exchange, timezone
 610        - last_price, open, day_high, day_low, previous_close, volume
 611        - market_cap, shares, pe_ratio, pb_ratio
 612        - year_high, year_low (52-week)
 613        - fifty_day_average, two_hundred_day_average
 614        - free_float, foreign_ratio
 615
 616        Examples:
 617            >>> stock = Ticker("THYAO")
 618            >>> stock.fast_info.market_cap
 619            370530000000
 620            >>> stock.fast_info['pe_ratio']
 621            2.8
 622            >>> stock.fast_info.keys()
 623            ['currency', 'exchange', 'timezone', ...]
 624        """
 625        if not hasattr(self, "_fast_info"):
 626            self._fast_info = FastInfo(self)
 627        return self._fast_info
 628
 629    @property
 630    def info(self) -> EnrichedInfo:
 631        """
 632        Get comprehensive ticker information with yfinance-compatible fields.
 633
 634        Returns:
 635            EnrichedInfo object providing dict-like access to:
 636
 637            Basic fields (always loaded, fast):
 638            - symbol, last, open, high, low, close, volume
 639            - change, change_percent, update_time
 640
 641            yfinance aliases (map to basic fields):
 642            - regularMarketPrice, currentPrice -> last
 643            - regularMarketOpen -> open
 644            - regularMarketDayHigh -> high
 645            - regularMarketDayLow -> low
 646            - regularMarketPreviousClose -> close
 647            - regularMarketVolume -> volume
 648
 649            Extended fields (lazy-loaded on access):
 650            - marketCap, trailingPE, priceToBook, enterpriseToEbitda
 651            - sharesOutstanding, fiftyTwoWeekHigh, fiftyTwoWeekLow
 652            - fiftyDayAverage, twoHundredDayAverage
 653            - floatShares, foreignRatio, netDebt
 654            - currency, exchange, timezone
 655
 656            Dividend fields (lazy-loaded on access):
 657            - dividendYield, exDividendDate
 658            - trailingAnnualDividendRate, trailingAnnualDividendYield
 659
 660        Examples:
 661            >>> stock = Ticker("THYAO")
 662            >>> stock.info['last']  # Basic field - fast
 663            268.5
 664            >>> stock.info['marketCap']  # Extended field - fetches İş Yatırım
 665            370530000000
 666            >>> stock.info['trailingPE']  # yfinance compatible name
 667            2.8
 668            >>> stock.info.get('dividendYield')  # Safe access
 669            1.28
 670            >>> stock.info.todict()  # Get all as regular dict
 671            {...}
 672        """
 673        if not hasattr(self, "_enriched_info"):
 674            self._enriched_info = EnrichedInfo(self)
 675        return self._enriched_info
 676
 677    def history(
 678        self,
 679        period: str = "1mo",
 680        interval: str = "1d",
 681        start: datetime | str | None = None,
 682        end: datetime | str | None = None,
 683        actions: bool = False,
 684        adjust: bool = True,
 685        auto_adjust: bool = False,
 686    ) -> pd.DataFrame:
 687        """
 688        Get historical OHLCV data.
 689
 690        Args:
 691            period: How much data to fetch. Valid periods:
 692                    1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max.
 693                    Ignored if start is provided.
 694            interval: Data granularity. Valid intervals:
 695                      1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo.
 696            start: Start date (string or datetime).
 697            end: End date (string or datetime). Defaults to today.
 698            actions: If True, include Dividends and Stock Splits columns.
 699                     Defaults to False.
 700            adjust: If True (default), return split-adjusted prices.
 701                    If False, return unadjusted (raw) prices.
 702            auto_adjust: If True, include an ``Adj Close`` column
 703                         (split + dividend adjusted, yfinance-style).
 704                         Defaults to False.
 705
 706        Returns:
 707            DataFrame with columns: Open, High, Low, Close, Volume.
 708            If actions=True, also includes Dividends and Stock Splits columns.
 709            If auto_adjust=True, also includes an Adj Close column.
 710            Index is the Date.
 711
 712        Examples:
 713            >>> stock = Ticker("THYAO")
 714            >>> stock.history(period="1mo")  # Last month
 715            >>> stock.history(period="1y", interval="1wk")  # Weekly for 1 year
 716            >>> stock.history(start="2024-01-01", end="2024-06-30")  # Date range
 717            >>> stock.history(period="1y", actions=True)  # With dividends/splits
 718            >>> stock.history(period="max", adjust=False)  # Raw unadjusted prices
 719            >>> stock.history(period="5y", auto_adjust=True)  # With Adj Close
 720        """
 721        # Parse dates if strings
 722        start_dt = self._parse_date(start) if start else None
 723        end_dt = self._parse_date(end) if end else None
 724
 725        df = self._tradingview.get_history(
 726            symbol=self._symbol,
 727            period=period,
 728            interval=interval,
 729            start=start_dt,
 730            end=end_dt,
 731        )
 732
 733        if not adjust and not df.empty:
 734            df = self._unadjust_prices(df)
 735
 736        if actions and not df.empty:
 737            df = self._add_actions_to_history(df)
 738
 739        if auto_adjust and not df.empty and "Close" in df.columns:
 740            try:
 741                divs = self.dividends
 742            except Exception:
 743                divs = pd.DataFrame()
 744            df = df.copy()
 745            df["Adj Close"] = _compute_adj_close(df["Close"], divs)
 746
 747        return df
 748
 749    def _add_actions_to_history(self, df: pd.DataFrame) -> pd.DataFrame:
 750        """
 751        Add Dividends and Stock Splits columns to historical data.
 752
 753        Args:
 754            df: Historical OHLCV DataFrame.
 755
 756        Returns:
 757            DataFrame with added Dividends and Stock Splits columns.
 758        """
 759        # Initialize columns with zeros
 760        df = df.copy()
 761        df["Dividends"] = 0.0
 762        df["Stock Splits"] = 0.0
 763
 764        # Get dividends
 765        try:
 766            divs = self.dividends
 767            if not divs.empty:
 768                for div_date, row in divs.iterrows():
 769                    # Use date() for timezone-agnostic comparison
 770                    div_date_only = pd.Timestamp(div_date).date()
 771                    for idx in df.index:
 772                        idx_date_only = pd.Timestamp(idx).date()
 773                        if div_date_only == idx_date_only:
 774                            df.loc[idx, "Dividends"] = row.get("Amount", 0)
 775                            break
 776        except Exception:
 777            pass
 778
 779        # Get stock splits (capital increases)
 780        try:
 781            splits = self.splits
 782            if not splits.empty:
 783                for split_date, row in splits.iterrows():
 784                    # Use date() for timezone-agnostic comparison
 785                    split_date_only = pd.Timestamp(split_date).date()
 786                    # Calculate split ratio
 787                    # BonusFromCapital + BonusFromDividend = total bonus percentage
 788                    bonus_pct = row.get("BonusFromCapital", 0) + row.get(
 789                        "BonusFromDividend", 0
 790                    )
 791                    if bonus_pct > 0:
 792                        # Convert percentage to split ratio (e.g., 20% bonus = 1.2 split)
 793                        split_ratio = 1 + (bonus_pct / 100)
 794                        for idx in df.index:
 795                            idx_date_only = pd.Timestamp(idx).date()
 796                            if split_date_only == idx_date_only:
 797                                df.loc[idx, "Stock Splits"] = split_ratio
 798                                break
 799        except Exception:
 800            pass
 801
 802        return df
 803
 804    def _unadjust_prices(self, df: pd.DataFrame) -> pd.DataFrame:
 805        """
 806        Reverse split adjustments to return raw unadjusted prices.
 807
 808        TradingView returns split-adjusted prices by default. This method
 809        uses the splits data from İş Yatırım to reverse the adjustments.
 810        """
 811        try:
 812            splits = self.splits
 813        except Exception:
 814            return df
 815
 816        if splits.empty:
 817            return df
 818
 819        # Collect split events with bonus ratios
 820        split_events = []
 821        for split_date, row in splits.iterrows():
 822            bonus_pct = row.get("BonusFromCapital", 0) + row.get(
 823                "BonusFromDividend", 0
 824            )
 825            if bonus_pct > 0:
 826                ratio = 1 + (bonus_pct / 100)
 827                split_events.append(
 828                    (pd.Timestamp(split_date, tz="Europe/Istanbul"), ratio)
 829                )
 830
 831        if not split_events:
 832            return df
 833
 834        # Sort by date ascending
 835        split_events.sort(key=lambda x: x[0])
 836
 837        df = df.copy()
 838
 839        # Build a vectorized adjustment factor series.
 840        # For each split, all rows BEFORE the split date get multiplied by the ratio.
 841        factor = pd.Series(1.0, index=df.index)
 842        for split_date, ratio in split_events:
 843            factor = factor * pd.Series(
 844                [ratio if idx < split_date else 1.0 for idx in df.index],
 845                index=df.index,
 846            )
 847
 848        price_cols = [c for c in ["Open", "High", "Low", "Close"] if c in df.columns]
 849        for col in price_cols:
 850            df[col] = df[col] * factor
 851        if "Volume" in df.columns:
 852            df["Volume"] = df["Volume"] / factor
 853
 854        return df
 855
 856    @cached_property
 857    def dividends(self) -> pd.DataFrame:
 858        """
 859        Get dividend history.
 860
 861        Returns:
 862            DataFrame with dividend history:
 863            - Amount: Dividend per share (TL)
 864            - GrossRate: Gross dividend rate (%)
 865            - NetRate: Net dividend rate (%)
 866            - TotalDividend: Total dividend distributed (TL)
 867
 868        Examples:
 869            >>> stock = Ticker("THYAO")
 870            >>> stock.dividends
 871                           Amount  GrossRate  NetRate  TotalDividend
 872            Date
 873            2025-09-02     3.442    344.20   292.57  4750000000.0
 874            2025-06-16     3.442    344.20   292.57  4750000000.0
 875        """
 876        return self._get_isyatirim().get_dividends(self._symbol)
 877
 878    @cached_property
 879    def splits(self) -> pd.DataFrame:
 880        """
 881        Get capital increase (split) history.
 882
 883        Note: Turkish market uses capital increases instead of traditional splits.
 884        - RightsIssue: Paid capital increase (bedelli)
 885        - BonusFromCapital: Free shares from capital reserves (bedelsiz iç kaynak)
 886        - BonusFromDividend: Free shares from dividend (bedelsiz temettüden)
 887
 888        Returns:
 889            DataFrame with capital increase history:
 890            - Capital: New capital after increase (TL)
 891            - RightsIssue: Rights issue rate (%)
 892            - BonusFromCapital: Bonus from capital (%)
 893            - BonusFromDividend: Bonus from dividend (%)
 894
 895        Examples:
 896            >>> stock = Ticker("THYAO")
 897            >>> stock.splits
 898                             Capital  RightsIssue  BonusFromCapital  BonusFromDividend
 899            Date
 900            2013-06-26  1380000000.0         0.0             15.00               0.0
 901            2011-07-11  1200000000.0         0.0              0.00              20.0
 902        """
 903        return self._get_isyatirim().get_capital_increases(self._symbol)
 904
 905    @cached_property
 906    def actions(self) -> pd.DataFrame:
 907        """
 908        Get combined dividends and splits history.
 909
 910        Returns:
 911            DataFrame with combined dividend and split actions:
 912            - Dividends: Dividend per share (TL) or 0
 913            - Splits: Combined split ratio (0 if no split)
 914
 915        Examples:
 916            >>> stock = Ticker("THYAO")
 917            >>> stock.actions
 918                         Dividends  Splits
 919            Date
 920            2025-09-02      3.442    0.0
 921            2013-06-26      0.000   15.0
 922        """
 923        dividends = self.dividends
 924        splits = self.splits
 925
 926        # Merge on index (Date)
 927        if dividends.empty and splits.empty:
 928            return pd.DataFrame(columns=["Dividends", "Splits"])
 929
 930        # Extract relevant columns
 931        div_series = dividends["Amount"] if not dividends.empty else pd.Series(dtype=float)
 932        split_series = (
 933            splits["BonusFromCapital"] + splits["BonusFromDividend"]
 934            if not splits.empty
 935            else pd.Series(dtype=float)
 936        )
 937
 938        # Combine into single DataFrame
 939        result = pd.DataFrame({"Dividends": div_series, "Splits": split_series})
 940        result = result.fillna(0)
 941        result = result.sort_index(ascending=False)
 942
 943        return result
 944
 945    def get_balance_sheet(
 946        self,
 947        quarterly: bool = False,
 948        financial_group: str | None = None,
 949        last_n: int | str | None = None,
 950    ) -> pd.DataFrame:
 951        """
 952        Get balance sheet data.
 953
 954        Args:
 955            quarterly: If True, return quarterly data. If False, return annual.
 956            financial_group: Financial group code. Use "UFRS" for banks,
 957                           "XI_29" for industrial companies. If None, defaults to XI_29.
 958            last_n: Number of periods to fetch. None for default (5), int for exact
 959                    count (e.g. 10 = 10 annual periods), "all" for maximum available.
 960
 961        Returns:
 962            DataFrame with balance sheet items as rows and periods as columns.
 963
 964        Examples:
 965            >>> stock = bp.Ticker("THYAO")
 966            >>> stock.get_balance_sheet()  # Annual, industrial (5 periods)
 967            >>> stock.get_balance_sheet(quarterly=True, last_n=20)  # 20 quarters
 968
 969            >>> bank = bp.Ticker("AKBNK")
 970            >>> bank.get_balance_sheet(financial_group="UFRS", last_n="all")
 971        """
 972        return self._get_isyatirim().get_financial_statements(
 973            symbol=self._symbol,
 974            statement_type="balance_sheet",
 975            quarterly=quarterly,
 976            financial_group=financial_group,
 977            last_n=last_n,
 978        )
 979
 980    def get_income_stmt(
 981        self,
 982        quarterly: bool = False,
 983        financial_group: str | None = None,
 984        last_n: int | str | None = None,
 985    ) -> pd.DataFrame:
 986        """
 987        Get income statement data.
 988
 989        Args:
 990            quarterly: If True, return quarterly data. If False, return annual.
 991            financial_group: Financial group code. Use "UFRS" for banks,
 992                           "XI_29" for industrial companies. If None, defaults to XI_29.
 993            last_n: Number of periods to fetch. None for default (5), int for exact
 994                    count (e.g. 10 = 10 annual periods), "all" for maximum available.
 995
 996        Returns:
 997            DataFrame with income statement items as rows and periods as columns.
 998
 999        Examples:
1000            >>> stock = bp.Ticker("THYAO")
1001            >>> stock.get_income_stmt()  # Annual (5 periods)
1002            >>> stock.get_income_stmt(quarterly=True, last_n=20)  # 20 quarters
1003
1004            >>> bank = bp.Ticker("AKBNK")
1005            >>> bank.get_income_stmt(quarterly=True, financial_group="UFRS")
1006        """
1007        return self._get_isyatirim().get_financial_statements(
1008            symbol=self._symbol,
1009            statement_type="income_stmt",
1010            quarterly=quarterly,
1011            financial_group=financial_group,
1012            last_n=last_n,
1013        )
1014
1015    def get_cashflow(
1016        self,
1017        quarterly: bool = False,
1018        financial_group: str | None = None,
1019        last_n: int | str | None = None,
1020    ) -> pd.DataFrame:
1021        """
1022        Get cash flow statement data.
1023
1024        Args:
1025            quarterly: If True, return quarterly data. If False, return annual.
1026            financial_group: Financial group code. Use "UFRS" for banks,
1027                           "XI_29" for industrial companies. If None, defaults to XI_29.
1028            last_n: Number of periods to fetch. None for default (5), int for exact
1029                    count (e.g. 10 = 10 annual periods), "all" for maximum available.
1030
1031        Returns:
1032            DataFrame with cash flow items as rows and periods as columns.
1033
1034        Examples:
1035            >>> stock = bp.Ticker("THYAO")
1036            >>> stock.get_cashflow()  # Annual (5 periods)
1037            >>> stock.get_cashflow(quarterly=True, last_n=20)  # 20 quarters
1038
1039            >>> bank = bp.Ticker("AKBNK")
1040            >>> bank.get_cashflow(financial_group="UFRS", last_n="all")
1041        """
1042        return self._get_isyatirim().get_financial_statements(
1043            symbol=self._symbol,
1044            statement_type="cashflow",
1045            quarterly=quarterly,
1046            financial_group=financial_group,
1047            last_n=last_n,
1048        )
1049
1050    # Legacy property aliases for backward compatibility
1051    @cached_property
1052    def balance_sheet(self) -> pd.DataFrame:
1053        """Annual balance sheet (use get_balance_sheet() for more options)."""
1054        return self.get_balance_sheet(quarterly=False)
1055
1056    @cached_property
1057    def quarterly_balance_sheet(self) -> pd.DataFrame:
1058        """Quarterly balance sheet (use get_balance_sheet(quarterly=True) for more options)."""
1059        return self.get_balance_sheet(quarterly=True)
1060
1061    @cached_property
1062    def income_stmt(self) -> pd.DataFrame:
1063        """Annual income statement (use get_income_stmt() for more options)."""
1064        return self.get_income_stmt(quarterly=False)
1065
1066    @cached_property
1067    def quarterly_income_stmt(self) -> pd.DataFrame:
1068        """Quarterly income statement (use get_income_stmt(quarterly=True) for more options)."""
1069        return self.get_income_stmt(quarterly=True)
1070
1071    @cached_property
1072    def cashflow(self) -> pd.DataFrame:
1073        """Annual cash flow (use get_cashflow() for more options)."""
1074        return self.get_cashflow(quarterly=False)
1075
1076    @cached_property
1077    def quarterly_cashflow(self) -> pd.DataFrame:
1078        """Quarterly cash flow (use get_cashflow(quarterly=True) for more options)."""
1079        return self.get_cashflow(quarterly=True)
1080
1081    def _calculate_ttm(self, quarterly_df: pd.DataFrame) -> pd.DataFrame:
1082        """
1083        Calculate trailing twelve months (TTM) by summing last 4 quarters.
1084
1085        Args:
1086            quarterly_df: DataFrame with quarterly data (columns in YYYYQN format).
1087
1088        Returns:
1089            DataFrame with single TTM column containing summed values.
1090        """
1091        if quarterly_df.empty or len(quarterly_df.columns) < 4:
1092            return pd.DataFrame(columns=["TTM"])
1093
1094        # First 4 columns = last 4 quarters (most recent first)
1095        last_4_quarters = quarterly_df.iloc[:, :4]
1096
1097        # Convert to numeric, coercing errors to NaN
1098        numeric_df = last_4_quarters.apply(pd.to_numeric, errors="coerce")
1099
1100        return numeric_df.sum(axis=1).to_frame(name="TTM")
1101
1102    def get_ttm_income_stmt(self, financial_group: str | None = None) -> pd.DataFrame:
1103        """
1104        Get trailing twelve months (TTM) income statement.
1105
1106        Calculates TTM by summing the last 4 quarters of income statement data.
1107
1108        Args:
1109            financial_group: Financial group code. Use "UFRS" for banks,
1110                           "XI_29" for industrial companies. If None, defaults to XI_29.
1111
1112        Returns:
1113            DataFrame with TTM column containing summed values for each line item.
1114
1115        Examples:
1116            >>> stock = bp.Ticker("THYAO")
1117            >>> stock.get_ttm_income_stmt()
1118
1119            >>> bank = bp.Ticker("AKBNK")
1120            >>> bank.get_ttm_income_stmt(financial_group="UFRS")
1121        """
1122        quarterly = self.get_income_stmt(quarterly=True, financial_group=financial_group)
1123        return self._calculate_ttm(quarterly)
1124
1125    def get_ttm_cashflow(self, financial_group: str | None = None) -> pd.DataFrame:
1126        """
1127        Get trailing twelve months (TTM) cash flow statement.
1128
1129        Calculates TTM by summing the last 4 quarters of cash flow data.
1130
1131        Args:
1132            financial_group: Financial group code. Use "UFRS" for banks,
1133                           "XI_29" for industrial companies. If None, defaults to XI_29.
1134
1135        Returns:
1136            DataFrame with TTM column containing summed values for each line item.
1137
1138        Examples:
1139            >>> stock = bp.Ticker("THYAO")
1140            >>> stock.get_ttm_cashflow()
1141
1142            >>> bank = bp.Ticker("AKBNK")
1143            >>> bank.get_ttm_cashflow(financial_group="UFRS")
1144        """
1145        quarterly = self.get_cashflow(quarterly=True, financial_group=financial_group)
1146        return self._calculate_ttm(quarterly)
1147
1148    # Legacy property aliases
1149    @cached_property
1150    def ttm_income_stmt(self) -> pd.DataFrame:
1151        """TTM income statement (use get_ttm_income_stmt() for banks)."""
1152        return self.get_ttm_income_stmt()
1153
1154    @cached_property
1155    def ttm_cashflow(self) -> pd.DataFrame:
1156        """TTM cash flow (use get_ttm_cashflow() for banks)."""
1157        return self.get_ttm_cashflow()
1158
1159    @cached_property
1160    def major_holders(self) -> pd.DataFrame:
1161        """
1162        Get major shareholders (ortaklık yapısı).
1163
1164        Returns:
1165            DataFrame with shareholder names and percentages:
1166            - Index: Holder name
1167            - Percentage: Ownership percentage (%)
1168
1169        Examples:
1170            >>> stock = Ticker("THYAO")
1171            >>> stock.major_holders
1172                                     Percentage
1173            Holder
1174            DiÄŸer                        50.88
1175            Türkiye Varlık Fonu          49.12
1176        """
1177        return self._get_isyatirim().get_major_holders(self._symbol)
1178
1179    @cached_property
1180    def recommendations(self) -> dict:
1181        """
1182        Get analyst recommendations and target price.
1183
1184        Returns:
1185            Dictionary with:
1186            - recommendation: Buy/Hold/Sell (AL/TUT/SAT)
1187            - target_price: Analyst target price (TL)
1188            - upside_potential: Expected upside (%)
1189
1190        Examples:
1191            >>> stock = Ticker("THYAO")
1192            >>> stock.recommendations
1193            {'recommendation': 'AL', 'target_price': 579.99, 'upside_potential': 116.01}
1194        """
1195        return self._get_isyatirim().get_recommendations(self._symbol)
1196
1197    @cached_property
1198    def recommendations_summary(self) -> dict[str, int]:
1199        """
1200        Get analyst recommendation summary with buy/hold/sell counts.
1201
1202        Aggregates individual analyst recommendations from hedeffiyat.com.tr
1203        into yfinance-compatible categories.
1204
1205        Returns:
1206            Dictionary with counts:
1207            - strongBuy: Strong buy recommendations
1208            - buy: Buy recommendations (includes "Endeks Üstü Getiri")
1209            - hold: Hold recommendations (includes "Nötr", "Endekse Paralel")
1210            - sell: Sell recommendations (includes "Endeks Altı Getiri")
1211            - strongSell: Strong sell recommendations
1212
1213        Examples:
1214            >>> stock = Ticker("THYAO")
1215            >>> stock.recommendations_summary
1216            {'strongBuy': 0, 'buy': 31, 'hold': 0, 'sell': 0, 'strongSell': 0}
1217        """
1218        return self._get_hedeffiyat().get_recommendations_summary(self._symbol)
1219
1220    @cached_property
1221    def news(self) -> pd.DataFrame:
1222        """
1223        Get recent KAP (Kamuyu Aydınlatma Platformu) disclosures for the stock.
1224
1225        Fetches directly from KAP - the official disclosure platform for
1226        publicly traded companies in Turkey.
1227
1228        Returns:
1229            DataFrame with columns:
1230            - Date: Disclosure date and time
1231            - Title: Disclosure headline
1232            - URL: Link to full disclosure on KAP
1233
1234        Examples:
1235            >>> stock = Ticker("THYAO")
1236            >>> stock.news
1237                              Date                                         Title                                         URL
1238            0  29.12.2025 19:21:18  Haber ve Söylentilere İlişkin Açıklama  https://www.kap.org.tr/tr/Bildirim/1530826
1239            1  29.12.2025 16:11:36  Payların Geri Alınmasına İlişkin Bildirim  https://www.kap.org.tr/tr/Bildirim/1530656
1240        """
1241        return self._get_kap().get_disclosures(self._symbol)
1242
1243    def get_news_content(self, disclosure_id: int | str) -> str | None:
1244        """
1245        Get full HTML content of a KAP disclosure by ID.
1246
1247        Args:
1248            disclosure_id: KAP disclosure ID from news DataFrame URL.
1249
1250        Returns:
1251            Raw HTML content or None if failed.
1252
1253        Examples:
1254            >>> stock = Ticker("THYAO")
1255            >>> html = stock.get_news_content(1530826)
1256        """
1257        return self._get_kap().get_disclosure_content(disclosure_id)
1258
1259    @cached_property
1260    def calendar(self) -> pd.DataFrame:
1261        """
1262        Get expected disclosure calendar for the stock from KAP.
1263
1264        Returns upcoming expected disclosures like financial reports,
1265        annual reports, sustainability reports, and corporate governance reports.
1266
1267        Returns:
1268            DataFrame with columns:
1269            - StartDate: Expected disclosure window start
1270            - EndDate: Expected disclosure window end
1271            - Subject: Type of disclosure (e.g., "Finansal Rapor")
1272            - Period: Report period (e.g., "Yıllık", "3 Aylık")
1273            - Year: Fiscal year
1274
1275        Examples:
1276            >>> stock = Ticker("THYAO")
1277            >>> stock.calendar
1278                  StartDate       EndDate               Subject   Period  Year
1279            0  01.01.2026  11.03.2026       Finansal Rapor   Yıllık  2025
1280            1  01.01.2026  11.03.2026    Faaliyet Raporu  Yıllık  2025
1281            2  01.04.2026  11.05.2026       Finansal Rapor  3 Aylık  2026
1282        """
1283        return self._get_kap().get_calendar(self._symbol)
1284
1285    @cached_property
1286    def isin(self) -> str | None:
1287        """
1288        Get ISIN (International Securities Identification Number) code.
1289
1290        ISIN is a 12-character alphanumeric code that uniquely identifies
1291        a security, standardized by ISO 6166.
1292
1293        Returns:
1294            ISIN code string (e.g., "TRATHYAO91M5") or None if not found.
1295
1296        Examples:
1297            >>> stock = Ticker("THYAO")
1298            >>> stock.isin
1299            'TRATHYAO91M5'
1300        """
1301        return self._get_isin_provider().get_isin(self._symbol)
1302
1303    @cached_property
1304    def analyst_price_targets(self) -> dict[str, float | int | None]:
1305        """
1306        Get analyst price target data from hedeffiyat.com.tr.
1307
1308        Returns aggregated price target information from multiple analysts.
1309
1310        Returns:
1311            Dictionary with:
1312            - current: Current stock price
1313            - low: Lowest analyst target price
1314            - high: Highest analyst target price
1315            - mean: Average target price
1316            - median: Median target price
1317            - numberOfAnalysts: Number of analysts covering the stock
1318
1319        Examples:
1320            >>> stock = Ticker("THYAO")
1321            >>> stock.analyst_price_targets
1322            {'current': 268.5, 'low': 388.0, 'high': 580.0, 'mean': 474.49,
1323             'median': 465.0, 'numberOfAnalysts': 19}
1324        """
1325        return self._get_hedeffiyat().get_price_targets(self._symbol)
1326
1327    @property
1328    def etf_holders(self) -> pd.DataFrame:
1329        """
1330        Get international ETFs that hold this stock.
1331
1332        Returns data from TradingView showing which ETFs hold this stock,
1333        including position value, weight, and ETF characteristics.
1334
1335        Returns:
1336            DataFrame with ETF holder information:
1337            - symbol: ETF ticker symbol
1338            - exchange: Exchange (AMEX, NASDAQ, LSE, etc.)
1339            - name: ETF full name
1340            - market_cap_usd: Position value in USD
1341            - holding_weight_pct: Weight percentage (0.09 = 0.09%)
1342            - issuer: ETF issuer (BlackRock, Vanguard, etc.)
1343            - management: Management style (Passive/Active)
1344            - focus: Investment focus (Total Market, Emerging Markets, etc.)
1345            - expense_ratio: Expense ratio (0.09 = 0.09%)
1346            - aum_usd: Total assets under management (USD)
1347            - price: Current ETF price
1348            - change_pct: Change percentage
1349
1350        Examples:
1351            >>> stock = Ticker("ASELS")
1352            >>> holders = stock.etf_holders
1353            >>> holders[['symbol', 'name', 'holding_weight_pct']].head()
1354               symbol                                      name  holding_weight_pct
1355            0    IEMG  iShares Core MSCI Emerging Markets ETF            0.090686
1356            1     VWO     Vanguard FTSE Emerging Markets ETF            0.060000
1357
1358            >>> print(f"Total ETFs: {len(holders)}")
1359            Total ETFs: 118
1360        """
1361        return self._get_etf_provider().get_etf_holders(self._symbol)
1362
1363    @cached_property
1364    def earnings_dates(self) -> pd.DataFrame:
1365        """
1366        Get upcoming earnings announcement dates.
1367
1368        Derived from KAP calendar, showing expected financial report dates.
1369        Compatible with yfinance earnings_dates format.
1370
1371        Returns:
1372            DataFrame with index as Earnings Date and columns:
1373            - EPS Estimate: Always None (not available for BIST)
1374            - Reported EPS: Always None (not available for BIST)
1375            - Surprise (%): Always None (not available for BIST)
1376
1377        Examples:
1378            >>> stock = Ticker("THYAO")
1379            >>> stock.earnings_dates
1380                            EPS Estimate  Reported EPS  Surprise(%)
1381            Earnings Date
1382            2026-03-11           None          None         None
1383            2026-05-11           None          None         None
1384        """
1385        cal = self.calendar
1386        if cal.empty:
1387            return pd.DataFrame(
1388                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1389            )
1390
1391        # Filter for financial reports only
1392        financial_reports = cal[
1393            cal["Subject"].str.contains("Finansal Rapor", case=False, na=False)
1394        ]
1395
1396        if financial_reports.empty:
1397            return pd.DataFrame(
1398                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1399            )
1400
1401        # Use EndDate as the earnings date (latest expected date)
1402        earnings_dates = []
1403        for _, row in financial_reports.iterrows():
1404            end_date = row.get("EndDate", "")
1405            if end_date:
1406                try:
1407                    # Parse Turkish date format (DD.MM.YYYY)
1408                    parsed = datetime.strptime(end_date, "%d.%m.%Y")
1409                    earnings_dates.append(parsed)
1410                except ValueError:
1411                    continue
1412
1413        if not earnings_dates:
1414            return pd.DataFrame(
1415                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1416            )
1417
1418        result = pd.DataFrame(
1419            {
1420                "EPS Estimate": [None] * len(earnings_dates),
1421                "Reported EPS": [None] * len(earnings_dates),
1422                "Surprise(%)": [None] * len(earnings_dates),
1423            },
1424            index=pd.DatetimeIndex(earnings_dates, name="Earnings Date"),
1425        )
1426        result = result.sort_index()
1427        return result
1428
1429    def _parse_date(self, date: str | datetime) -> datetime:
1430        """Parse a date string to datetime."""
1431        if isinstance(date, datetime):
1432            return date
1433        # Try common formats
1434        for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"]:
1435            try:
1436                return datetime.strptime(date, fmt)
1437            except ValueError:
1438                continue
1439        raise ValueError(f"Could not parse date: {date}")
1440
1441    def _get_ta_symbol_info(self) -> tuple[str, str]:
1442        """Get TradingView symbol and screener for TA signals.
1443
1444        Returns:
1445            Tuple of (tv_symbol, screener) for TradingView Scanner API.
1446        """
1447        return (f"BIST:{self._symbol}", "turkey")
1448
1449    def __repr__(self) -> str:
1450        return f"Ticker('{self._symbol}')"

A yfinance-like interface for Turkish stock data.

Examples:

import borsapy as bp stock = bp.Ticker("THYAO") stock.info {'symbol': 'THYAO', 'last': 268.5, ...} stock.history(period="1mo") Open High Low Close Volume Date 2024-12-01 265.00 268.00 264.00 267.50 12345678 ...

Ticker(symbol: str)
539    def __init__(self, symbol: str):
540        """
541        Initialize a Ticker object.
542
543        Args:
544            symbol: Stock symbol (e.g., "THYAO", "GARAN", "ASELS").
545                    The ".IS" or ".E" suffix is optional and will be removed.
546        """
547        self._symbol = symbol.upper().replace(".IS", "").replace(".E", "")
548        self._tradingview = get_tradingview_provider()
549        self._isyatirim = None  # Lazy load for financial statements
550        self._kap = None  # Lazy load for KAP disclosures
551        self._isin_provider = None  # Lazy load for ISIN lookup
552        self._hedeffiyat = None  # Lazy load for analyst price targets
553        self._etf_provider = None  # Lazy load for ETF holders

Initialize a Ticker object.

Args: symbol: Stock symbol (e.g., "THYAO", "GARAN", "ASELS"). The ".IS" or ".E" suffix is optional and will be removed.

symbol: str
598    @property
599    def symbol(self) -> str:
600        """Return the ticker symbol."""
601        return self._symbol

Return the ticker symbol.

fast_info: borsapy.ticker.FastInfo
603    @property
604    def fast_info(self) -> FastInfo:
605        """
606        Get fast access to common ticker information.
607
608        Returns a FastInfo object with quick access to frequently used data:
609        - currency, exchange, timezone
610        - last_price, open, day_high, day_low, previous_close, volume
611        - market_cap, shares, pe_ratio, pb_ratio
612        - year_high, year_low (52-week)
613        - fifty_day_average, two_hundred_day_average
614        - free_float, foreign_ratio
615
616        Examples:
617            >>> stock = Ticker("THYAO")
618            >>> stock.fast_info.market_cap
619            370530000000
620            >>> stock.fast_info['pe_ratio']
621            2.8
622            >>> stock.fast_info.keys()
623            ['currency', 'exchange', 'timezone', ...]
624        """
625        if not hasattr(self, "_fast_info"):
626            self._fast_info = FastInfo(self)
627        return self._fast_info

Get fast access to common ticker information.

Returns a FastInfo object with quick access to frequently used data:

  • currency, exchange, timezone
  • last_price, open, day_high, day_low, previous_close, volume
  • market_cap, shares, pe_ratio, pb_ratio
  • year_high, year_low (52-week)
  • fifty_day_average, two_hundred_day_average
  • free_float, foreign_ratio

Examples:

stock = Ticker("THYAO") stock.fast_info.market_cap 370530000000 stock.fast_info['pe_ratio'] 2.8 stock.fast_info.keys() ['currency', 'exchange', 'timezone', ...]

info: borsapy.ticker.EnrichedInfo
629    @property
630    def info(self) -> EnrichedInfo:
631        """
632        Get comprehensive ticker information with yfinance-compatible fields.
633
634        Returns:
635            EnrichedInfo object providing dict-like access to:
636
637            Basic fields (always loaded, fast):
638            - symbol, last, open, high, low, close, volume
639            - change, change_percent, update_time
640
641            yfinance aliases (map to basic fields):
642            - regularMarketPrice, currentPrice -> last
643            - regularMarketOpen -> open
644            - regularMarketDayHigh -> high
645            - regularMarketDayLow -> low
646            - regularMarketPreviousClose -> close
647            - regularMarketVolume -> volume
648
649            Extended fields (lazy-loaded on access):
650            - marketCap, trailingPE, priceToBook, enterpriseToEbitda
651            - sharesOutstanding, fiftyTwoWeekHigh, fiftyTwoWeekLow
652            - fiftyDayAverage, twoHundredDayAverage
653            - floatShares, foreignRatio, netDebt
654            - currency, exchange, timezone
655
656            Dividend fields (lazy-loaded on access):
657            - dividendYield, exDividendDate
658            - trailingAnnualDividendRate, trailingAnnualDividendYield
659
660        Examples:
661            >>> stock = Ticker("THYAO")
662            >>> stock.info['last']  # Basic field - fast
663            268.5
664            >>> stock.info['marketCap']  # Extended field - fetches İş Yatırım
665            370530000000
666            >>> stock.info['trailingPE']  # yfinance compatible name
667            2.8
668            >>> stock.info.get('dividendYield')  # Safe access
669            1.28
670            >>> stock.info.todict()  # Get all as regular dict
671            {...}
672        """
673        if not hasattr(self, "_enriched_info"):
674            self._enriched_info = EnrichedInfo(self)
675        return self._enriched_info

Get comprehensive ticker information with yfinance-compatible fields.

Returns: EnrichedInfo object providing dict-like access to:

Basic fields (always loaded, fast):
- symbol, last, open, high, low, close, volume
- change, change_percent, update_time

yfinance aliases (map to basic fields):
- regularMarketPrice, currentPrice -> last
- regularMarketOpen -> open
- regularMarketDayHigh -> high
- regularMarketDayLow -> low
- regularMarketPreviousClose -> close
- regularMarketVolume -> volume

Extended fields (lazy-loaded on access):
- marketCap, trailingPE, priceToBook, enterpriseToEbitda
- sharesOutstanding, fiftyTwoWeekHigh, fiftyTwoWeekLow
- fiftyDayAverage, twoHundredDayAverage
- floatShares, foreignRatio, netDebt
- currency, exchange, timezone

Dividend fields (lazy-loaded on access):
- dividendYield, exDividendDate
- trailingAnnualDividendRate, trailingAnnualDividendYield

Examples:

stock = Ticker("THYAO") stock.info['last'] # Basic field - fast 268.5 stock.info['marketCap'] # Extended field - fetches İş Yatırım 370530000000 stock.info['trailingPE'] # yfinance compatible name 2.8 stock.info.get('dividendYield') # Safe access 1.28 stock.info.todict() # Get all as regular dict {...}

def history( self, period: str = '1mo', interval: str = '1d', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None, actions: bool = False, adjust: bool = True, auto_adjust: bool = False) -> pandas.core.frame.DataFrame:
677    def history(
678        self,
679        period: str = "1mo",
680        interval: str = "1d",
681        start: datetime | str | None = None,
682        end: datetime | str | None = None,
683        actions: bool = False,
684        adjust: bool = True,
685        auto_adjust: bool = False,
686    ) -> pd.DataFrame:
687        """
688        Get historical OHLCV data.
689
690        Args:
691            period: How much data to fetch. Valid periods:
692                    1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max.
693                    Ignored if start is provided.
694            interval: Data granularity. Valid intervals:
695                      1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo.
696            start: Start date (string or datetime).
697            end: End date (string or datetime). Defaults to today.
698            actions: If True, include Dividends and Stock Splits columns.
699                     Defaults to False.
700            adjust: If True (default), return split-adjusted prices.
701                    If False, return unadjusted (raw) prices.
702            auto_adjust: If True, include an ``Adj Close`` column
703                         (split + dividend adjusted, yfinance-style).
704                         Defaults to False.
705
706        Returns:
707            DataFrame with columns: Open, High, Low, Close, Volume.
708            If actions=True, also includes Dividends and Stock Splits columns.
709            If auto_adjust=True, also includes an Adj Close column.
710            Index is the Date.
711
712        Examples:
713            >>> stock = Ticker("THYAO")
714            >>> stock.history(period="1mo")  # Last month
715            >>> stock.history(period="1y", interval="1wk")  # Weekly for 1 year
716            >>> stock.history(start="2024-01-01", end="2024-06-30")  # Date range
717            >>> stock.history(period="1y", actions=True)  # With dividends/splits
718            >>> stock.history(period="max", adjust=False)  # Raw unadjusted prices
719            >>> stock.history(period="5y", auto_adjust=True)  # With Adj Close
720        """
721        # Parse dates if strings
722        start_dt = self._parse_date(start) if start else None
723        end_dt = self._parse_date(end) if end else None
724
725        df = self._tradingview.get_history(
726            symbol=self._symbol,
727            period=period,
728            interval=interval,
729            start=start_dt,
730            end=end_dt,
731        )
732
733        if not adjust and not df.empty:
734            df = self._unadjust_prices(df)
735
736        if actions and not df.empty:
737            df = self._add_actions_to_history(df)
738
739        if auto_adjust and not df.empty and "Close" in df.columns:
740            try:
741                divs = self.dividends
742            except Exception:
743                divs = pd.DataFrame()
744            df = df.copy()
745            df["Adj Close"] = _compute_adj_close(df["Close"], divs)
746
747        return df

Get historical OHLCV data.

Args: period: How much data to fetch. Valid periods: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max. Ignored if start is provided. interval: Data granularity. Valid intervals: 1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo. start: Start date (string or datetime). end: End date (string or datetime). Defaults to today. actions: If True, include Dividends and Stock Splits columns. Defaults to False. adjust: If True (default), return split-adjusted prices. If False, return unadjusted (raw) prices. auto_adjust: If True, include an Adj Close column (split + dividend adjusted, yfinance-style). Defaults to False.

Returns: DataFrame with columns: Open, High, Low, Close, Volume. If actions=True, also includes Dividends and Stock Splits columns. If auto_adjust=True, also includes an Adj Close column. Index is the Date.

Examples:

stock = Ticker("THYAO") stock.history(period="1mo") # Last month stock.history(period="1y", interval="1wk") # Weekly for 1 year stock.history(start="2024-01-01", end="2024-06-30") # Date range stock.history(period="1y", actions=True) # With dividends/splits stock.history(period="max", adjust=False) # Raw unadjusted prices stock.history(period="5y", auto_adjust=True) # With Adj Close

dividends: pandas.core.frame.DataFrame
856    @cached_property
857    def dividends(self) -> pd.DataFrame:
858        """
859        Get dividend history.
860
861        Returns:
862            DataFrame with dividend history:
863            - Amount: Dividend per share (TL)
864            - GrossRate: Gross dividend rate (%)
865            - NetRate: Net dividend rate (%)
866            - TotalDividend: Total dividend distributed (TL)
867
868        Examples:
869            >>> stock = Ticker("THYAO")
870            >>> stock.dividends
871                           Amount  GrossRate  NetRate  TotalDividend
872            Date
873            2025-09-02     3.442    344.20   292.57  4750000000.0
874            2025-06-16     3.442    344.20   292.57  4750000000.0
875        """
876        return self._get_isyatirim().get_dividends(self._symbol)

Get dividend history.

Returns: DataFrame with dividend history: - Amount: Dividend per share (TL) - GrossRate: Gross dividend rate (%) - NetRate: Net dividend rate (%) - TotalDividend: Total dividend distributed (TL)

Examples:

stock = Ticker("THYAO") stock.dividends Amount GrossRate NetRate TotalDividend Date 2025-09-02 3.442 344.20 292.57 4750000000.0 2025-06-16 3.442 344.20 292.57 4750000000.0

splits: pandas.core.frame.DataFrame
878    @cached_property
879    def splits(self) -> pd.DataFrame:
880        """
881        Get capital increase (split) history.
882
883        Note: Turkish market uses capital increases instead of traditional splits.
884        - RightsIssue: Paid capital increase (bedelli)
885        - BonusFromCapital: Free shares from capital reserves (bedelsiz iç kaynak)
886        - BonusFromDividend: Free shares from dividend (bedelsiz temettüden)
887
888        Returns:
889            DataFrame with capital increase history:
890            - Capital: New capital after increase (TL)
891            - RightsIssue: Rights issue rate (%)
892            - BonusFromCapital: Bonus from capital (%)
893            - BonusFromDividend: Bonus from dividend (%)
894
895        Examples:
896            >>> stock = Ticker("THYAO")
897            >>> stock.splits
898                             Capital  RightsIssue  BonusFromCapital  BonusFromDividend
899            Date
900            2013-06-26  1380000000.0         0.0             15.00               0.0
901            2011-07-11  1200000000.0         0.0              0.00              20.0
902        """
903        return self._get_isyatirim().get_capital_increases(self._symbol)

Get capital increase (split) history.

Note: Turkish market uses capital increases instead of traditional splits.

  • RightsIssue: Paid capital increase (bedelli)
  • BonusFromCapital: Free shares from capital reserves (bedelsiz iç kaynak)
  • BonusFromDividend: Free shares from dividend (bedelsiz temettüden)

Returns: DataFrame with capital increase history: - Capital: New capital after increase (TL) - RightsIssue: Rights issue rate (%) - BonusFromCapital: Bonus from capital (%) - BonusFromDividend: Bonus from dividend (%)

Examples:

stock = Ticker("THYAO") stock.splits Capital RightsIssue BonusFromCapital BonusFromDividend Date 2013-06-26 1380000000.0 0.0 15.00 0.0 2011-07-11 1200000000.0 0.0 0.00 20.0

actions: pandas.core.frame.DataFrame
905    @cached_property
906    def actions(self) -> pd.DataFrame:
907        """
908        Get combined dividends and splits history.
909
910        Returns:
911            DataFrame with combined dividend and split actions:
912            - Dividends: Dividend per share (TL) or 0
913            - Splits: Combined split ratio (0 if no split)
914
915        Examples:
916            >>> stock = Ticker("THYAO")
917            >>> stock.actions
918                         Dividends  Splits
919            Date
920            2025-09-02      3.442    0.0
921            2013-06-26      0.000   15.0
922        """
923        dividends = self.dividends
924        splits = self.splits
925
926        # Merge on index (Date)
927        if dividends.empty and splits.empty:
928            return pd.DataFrame(columns=["Dividends", "Splits"])
929
930        # Extract relevant columns
931        div_series = dividends["Amount"] if not dividends.empty else pd.Series(dtype=float)
932        split_series = (
933            splits["BonusFromCapital"] + splits["BonusFromDividend"]
934            if not splits.empty
935            else pd.Series(dtype=float)
936        )
937
938        # Combine into single DataFrame
939        result = pd.DataFrame({"Dividends": div_series, "Splits": split_series})
940        result = result.fillna(0)
941        result = result.sort_index(ascending=False)
942
943        return result

Get combined dividends and splits history.

Returns: DataFrame with combined dividend and split actions: - Dividends: Dividend per share (TL) or 0 - Splits: Combined split ratio (0 if no split)

Examples:

stock = Ticker("THYAO") stock.actions Dividends Splits Date 2025-09-02 3.442 0.0 2013-06-26 0.000 15.0

def get_balance_sheet( self, quarterly: bool = False, financial_group: str | None = None, last_n: int | str | None = None) -> pandas.core.frame.DataFrame:
945    def get_balance_sheet(
946        self,
947        quarterly: bool = False,
948        financial_group: str | None = None,
949        last_n: int | str | None = None,
950    ) -> pd.DataFrame:
951        """
952        Get balance sheet data.
953
954        Args:
955            quarterly: If True, return quarterly data. If False, return annual.
956            financial_group: Financial group code. Use "UFRS" for banks,
957                           "XI_29" for industrial companies. If None, defaults to XI_29.
958            last_n: Number of periods to fetch. None for default (5), int for exact
959                    count (e.g. 10 = 10 annual periods), "all" for maximum available.
960
961        Returns:
962            DataFrame with balance sheet items as rows and periods as columns.
963
964        Examples:
965            >>> stock = bp.Ticker("THYAO")
966            >>> stock.get_balance_sheet()  # Annual, industrial (5 periods)
967            >>> stock.get_balance_sheet(quarterly=True, last_n=20)  # 20 quarters
968
969            >>> bank = bp.Ticker("AKBNK")
970            >>> bank.get_balance_sheet(financial_group="UFRS", last_n="all")
971        """
972        return self._get_isyatirim().get_financial_statements(
973            symbol=self._symbol,
974            statement_type="balance_sheet",
975            quarterly=quarterly,
976            financial_group=financial_group,
977            last_n=last_n,
978        )

Get balance sheet data.

Args: quarterly: If True, return quarterly data. If False, return annual. financial_group: Financial group code. Use "UFRS" for banks, "XI_29" for industrial companies. If None, defaults to XI_29. last_n: Number of periods to fetch. None for default (5), int for exact count (e.g. 10 = 10 annual periods), "all" for maximum available.

Returns: DataFrame with balance sheet items as rows and periods as columns.

Examples:

stock = bp.Ticker("THYAO") stock.get_balance_sheet() # Annual, industrial (5 periods) stock.get_balance_sheet(quarterly=True, last_n=20) # 20 quarters

>>> bank = bp.Ticker("AKBNK")
>>> bank.get_balance_sheet(financial_group="UFRS", last_n="all")
def get_income_stmt( self, quarterly: bool = False, financial_group: str | None = None, last_n: int | str | None = None) -> pandas.core.frame.DataFrame:
 980    def get_income_stmt(
 981        self,
 982        quarterly: bool = False,
 983        financial_group: str | None = None,
 984        last_n: int | str | None = None,
 985    ) -> pd.DataFrame:
 986        """
 987        Get income statement data.
 988
 989        Args:
 990            quarterly: If True, return quarterly data. If False, return annual.
 991            financial_group: Financial group code. Use "UFRS" for banks,
 992                           "XI_29" for industrial companies. If None, defaults to XI_29.
 993            last_n: Number of periods to fetch. None for default (5), int for exact
 994                    count (e.g. 10 = 10 annual periods), "all" for maximum available.
 995
 996        Returns:
 997            DataFrame with income statement items as rows and periods as columns.
 998
 999        Examples:
1000            >>> stock = bp.Ticker("THYAO")
1001            >>> stock.get_income_stmt()  # Annual (5 periods)
1002            >>> stock.get_income_stmt(quarterly=True, last_n=20)  # 20 quarters
1003
1004            >>> bank = bp.Ticker("AKBNK")
1005            >>> bank.get_income_stmt(quarterly=True, financial_group="UFRS")
1006        """
1007        return self._get_isyatirim().get_financial_statements(
1008            symbol=self._symbol,
1009            statement_type="income_stmt",
1010            quarterly=quarterly,
1011            financial_group=financial_group,
1012            last_n=last_n,
1013        )

Get income statement data.

Args: quarterly: If True, return quarterly data. If False, return annual. financial_group: Financial group code. Use "UFRS" for banks, "XI_29" for industrial companies. If None, defaults to XI_29. last_n: Number of periods to fetch. None for default (5), int for exact count (e.g. 10 = 10 annual periods), "all" for maximum available.

Returns: DataFrame with income statement items as rows and periods as columns.

Examples:

stock = bp.Ticker("THYAO") stock.get_income_stmt() # Annual (5 periods) stock.get_income_stmt(quarterly=True, last_n=20) # 20 quarters

>>> bank = bp.Ticker("AKBNK")
>>> bank.get_income_stmt(quarterly=True, financial_group="UFRS")
def get_cashflow( self, quarterly: bool = False, financial_group: str | None = None, last_n: int | str | None = None) -> pandas.core.frame.DataFrame:
1015    def get_cashflow(
1016        self,
1017        quarterly: bool = False,
1018        financial_group: str | None = None,
1019        last_n: int | str | None = None,
1020    ) -> pd.DataFrame:
1021        """
1022        Get cash flow statement data.
1023
1024        Args:
1025            quarterly: If True, return quarterly data. If False, return annual.
1026            financial_group: Financial group code. Use "UFRS" for banks,
1027                           "XI_29" for industrial companies. If None, defaults to XI_29.
1028            last_n: Number of periods to fetch. None for default (5), int for exact
1029                    count (e.g. 10 = 10 annual periods), "all" for maximum available.
1030
1031        Returns:
1032            DataFrame with cash flow items as rows and periods as columns.
1033
1034        Examples:
1035            >>> stock = bp.Ticker("THYAO")
1036            >>> stock.get_cashflow()  # Annual (5 periods)
1037            >>> stock.get_cashflow(quarterly=True, last_n=20)  # 20 quarters
1038
1039            >>> bank = bp.Ticker("AKBNK")
1040            >>> bank.get_cashflow(financial_group="UFRS", last_n="all")
1041        """
1042        return self._get_isyatirim().get_financial_statements(
1043            symbol=self._symbol,
1044            statement_type="cashflow",
1045            quarterly=quarterly,
1046            financial_group=financial_group,
1047            last_n=last_n,
1048        )

Get cash flow statement data.

Args: quarterly: If True, return quarterly data. If False, return annual. financial_group: Financial group code. Use "UFRS" for banks, "XI_29" for industrial companies. If None, defaults to XI_29. last_n: Number of periods to fetch. None for default (5), int for exact count (e.g. 10 = 10 annual periods), "all" for maximum available.

Returns: DataFrame with cash flow items as rows and periods as columns.

Examples:

stock = bp.Ticker("THYAO") stock.get_cashflow() # Annual (5 periods) stock.get_cashflow(quarterly=True, last_n=20) # 20 quarters

>>> bank = bp.Ticker("AKBNK")
>>> bank.get_cashflow(financial_group="UFRS", last_n="all")
balance_sheet: pandas.core.frame.DataFrame
1051    @cached_property
1052    def balance_sheet(self) -> pd.DataFrame:
1053        """Annual balance sheet (use get_balance_sheet() for more options)."""
1054        return self.get_balance_sheet(quarterly=False)

Annual balance sheet (use get_balance_sheet() for more options).

quarterly_balance_sheet: pandas.core.frame.DataFrame
1056    @cached_property
1057    def quarterly_balance_sheet(self) -> pd.DataFrame:
1058        """Quarterly balance sheet (use get_balance_sheet(quarterly=True) for more options)."""
1059        return self.get_balance_sheet(quarterly=True)

Quarterly balance sheet (use get_balance_sheet(quarterly=True) for more options).

income_stmt: pandas.core.frame.DataFrame
1061    @cached_property
1062    def income_stmt(self) -> pd.DataFrame:
1063        """Annual income statement (use get_income_stmt() for more options)."""
1064        return self.get_income_stmt(quarterly=False)

Annual income statement (use get_income_stmt() for more options).

quarterly_income_stmt: pandas.core.frame.DataFrame
1066    @cached_property
1067    def quarterly_income_stmt(self) -> pd.DataFrame:
1068        """Quarterly income statement (use get_income_stmt(quarterly=True) for more options)."""
1069        return self.get_income_stmt(quarterly=True)

Quarterly income statement (use get_income_stmt(quarterly=True) for more options).

cashflow: pandas.core.frame.DataFrame
1071    @cached_property
1072    def cashflow(self) -> pd.DataFrame:
1073        """Annual cash flow (use get_cashflow() for more options)."""
1074        return self.get_cashflow(quarterly=False)

Annual cash flow (use get_cashflow() for more options).

quarterly_cashflow: pandas.core.frame.DataFrame
1076    @cached_property
1077    def quarterly_cashflow(self) -> pd.DataFrame:
1078        """Quarterly cash flow (use get_cashflow(quarterly=True) for more options)."""
1079        return self.get_cashflow(quarterly=True)

Quarterly cash flow (use get_cashflow(quarterly=True) for more options).

def get_ttm_income_stmt(self, financial_group: str | None = None) -> pandas.core.frame.DataFrame:
1102    def get_ttm_income_stmt(self, financial_group: str | None = None) -> pd.DataFrame:
1103        """
1104        Get trailing twelve months (TTM) income statement.
1105
1106        Calculates TTM by summing the last 4 quarters of income statement data.
1107
1108        Args:
1109            financial_group: Financial group code. Use "UFRS" for banks,
1110                           "XI_29" for industrial companies. If None, defaults to XI_29.
1111
1112        Returns:
1113            DataFrame with TTM column containing summed values for each line item.
1114
1115        Examples:
1116            >>> stock = bp.Ticker("THYAO")
1117            >>> stock.get_ttm_income_stmt()
1118
1119            >>> bank = bp.Ticker("AKBNK")
1120            >>> bank.get_ttm_income_stmt(financial_group="UFRS")
1121        """
1122        quarterly = self.get_income_stmt(quarterly=True, financial_group=financial_group)
1123        return self._calculate_ttm(quarterly)

Get trailing twelve months (TTM) income statement.

Calculates TTM by summing the last 4 quarters of income statement data.

Args: financial_group: Financial group code. Use "UFRS" for banks, "XI_29" for industrial companies. If None, defaults to XI_29.

Returns: DataFrame with TTM column containing summed values for each line item.

Examples:

stock = bp.Ticker("THYAO") stock.get_ttm_income_stmt()

>>> bank = bp.Ticker("AKBNK")
>>> bank.get_ttm_income_stmt(financial_group="UFRS")
def get_ttm_cashflow(self, financial_group: str | None = None) -> pandas.core.frame.DataFrame:
1125    def get_ttm_cashflow(self, financial_group: str | None = None) -> pd.DataFrame:
1126        """
1127        Get trailing twelve months (TTM) cash flow statement.
1128
1129        Calculates TTM by summing the last 4 quarters of cash flow data.
1130
1131        Args:
1132            financial_group: Financial group code. Use "UFRS" for banks,
1133                           "XI_29" for industrial companies. If None, defaults to XI_29.
1134
1135        Returns:
1136            DataFrame with TTM column containing summed values for each line item.
1137
1138        Examples:
1139            >>> stock = bp.Ticker("THYAO")
1140            >>> stock.get_ttm_cashflow()
1141
1142            >>> bank = bp.Ticker("AKBNK")
1143            >>> bank.get_ttm_cashflow(financial_group="UFRS")
1144        """
1145        quarterly = self.get_cashflow(quarterly=True, financial_group=financial_group)
1146        return self._calculate_ttm(quarterly)

Get trailing twelve months (TTM) cash flow statement.

Calculates TTM by summing the last 4 quarters of cash flow data.

Args: financial_group: Financial group code. Use "UFRS" for banks, "XI_29" for industrial companies. If None, defaults to XI_29.

Returns: DataFrame with TTM column containing summed values for each line item.

Examples:

stock = bp.Ticker("THYAO") stock.get_ttm_cashflow()

>>> bank = bp.Ticker("AKBNK")
>>> bank.get_ttm_cashflow(financial_group="UFRS")
ttm_income_stmt: pandas.core.frame.DataFrame
1149    @cached_property
1150    def ttm_income_stmt(self) -> pd.DataFrame:
1151        """TTM income statement (use get_ttm_income_stmt() for banks)."""
1152        return self.get_ttm_income_stmt()

TTM income statement (use get_ttm_income_stmt() for banks).

ttm_cashflow: pandas.core.frame.DataFrame
1154    @cached_property
1155    def ttm_cashflow(self) -> pd.DataFrame:
1156        """TTM cash flow (use get_ttm_cashflow() for banks)."""
1157        return self.get_ttm_cashflow()

TTM cash flow (use get_ttm_cashflow() for banks).

major_holders: pandas.core.frame.DataFrame
1159    @cached_property
1160    def major_holders(self) -> pd.DataFrame:
1161        """
1162        Get major shareholders (ortaklık yapısı).
1163
1164        Returns:
1165            DataFrame with shareholder names and percentages:
1166            - Index: Holder name
1167            - Percentage: Ownership percentage (%)
1168
1169        Examples:
1170            >>> stock = Ticker("THYAO")
1171            >>> stock.major_holders
1172                                     Percentage
1173            Holder
1174            DiÄŸer                        50.88
1175            Türkiye Varlık Fonu          49.12
1176        """
1177        return self._get_isyatirim().get_major_holders(self._symbol)

Get major shareholders (ortaklık yapısı).

Returns: DataFrame with shareholder names and percentages: - Index: Holder name - Percentage: Ownership percentage (%)

Examples:

stock = Ticker("THYAO") stock.major_holders Percentage Holder Diğer 50.88 Türkiye Varlık Fonu 49.12

recommendations: dict
1179    @cached_property
1180    def recommendations(self) -> dict:
1181        """
1182        Get analyst recommendations and target price.
1183
1184        Returns:
1185            Dictionary with:
1186            - recommendation: Buy/Hold/Sell (AL/TUT/SAT)
1187            - target_price: Analyst target price (TL)
1188            - upside_potential: Expected upside (%)
1189
1190        Examples:
1191            >>> stock = Ticker("THYAO")
1192            >>> stock.recommendations
1193            {'recommendation': 'AL', 'target_price': 579.99, 'upside_potential': 116.01}
1194        """
1195        return self._get_isyatirim().get_recommendations(self._symbol)

Get analyst recommendations and target price.

Returns: Dictionary with: - recommendation: Buy/Hold/Sell (AL/TUT/SAT) - target_price: Analyst target price (TL) - upside_potential: Expected upside (%)

Examples:

stock = Ticker("THYAO") stock.recommendations {'recommendation': 'AL', 'target_price': 579.99, 'upside_potential': 116.01}

recommendations_summary: dict[str, int]
1197    @cached_property
1198    def recommendations_summary(self) -> dict[str, int]:
1199        """
1200        Get analyst recommendation summary with buy/hold/sell counts.
1201
1202        Aggregates individual analyst recommendations from hedeffiyat.com.tr
1203        into yfinance-compatible categories.
1204
1205        Returns:
1206            Dictionary with counts:
1207            - strongBuy: Strong buy recommendations
1208            - buy: Buy recommendations (includes "Endeks Üstü Getiri")
1209            - hold: Hold recommendations (includes "Nötr", "Endekse Paralel")
1210            - sell: Sell recommendations (includes "Endeks Altı Getiri")
1211            - strongSell: Strong sell recommendations
1212
1213        Examples:
1214            >>> stock = Ticker("THYAO")
1215            >>> stock.recommendations_summary
1216            {'strongBuy': 0, 'buy': 31, 'hold': 0, 'sell': 0, 'strongSell': 0}
1217        """
1218        return self._get_hedeffiyat().get_recommendations_summary(self._symbol)

Get analyst recommendation summary with buy/hold/sell counts.

Aggregates individual analyst recommendations from hedeffiyat.com.tr into yfinance-compatible categories.

Returns: Dictionary with counts: - strongBuy: Strong buy recommendations - buy: Buy recommendations (includes "Endeks Üstü Getiri") - hold: Hold recommendations (includes "Nötr", "Endekse Paralel") - sell: Sell recommendations (includes "Endeks Altı Getiri") - strongSell: Strong sell recommendations

Examples:

stock = Ticker("THYAO") stock.recommendations_summary {'strongBuy': 0, 'buy': 31, 'hold': 0, 'sell': 0, 'strongSell': 0}

news: pandas.core.frame.DataFrame
1220    @cached_property
1221    def news(self) -> pd.DataFrame:
1222        """
1223        Get recent KAP (Kamuyu Aydınlatma Platformu) disclosures for the stock.
1224
1225        Fetches directly from KAP - the official disclosure platform for
1226        publicly traded companies in Turkey.
1227
1228        Returns:
1229            DataFrame with columns:
1230            - Date: Disclosure date and time
1231            - Title: Disclosure headline
1232            - URL: Link to full disclosure on KAP
1233
1234        Examples:
1235            >>> stock = Ticker("THYAO")
1236            >>> stock.news
1237                              Date                                         Title                                         URL
1238            0  29.12.2025 19:21:18  Haber ve Söylentilere İlişkin Açıklama  https://www.kap.org.tr/tr/Bildirim/1530826
1239            1  29.12.2025 16:11:36  Payların Geri Alınmasına İlişkin Bildirim  https://www.kap.org.tr/tr/Bildirim/1530656
1240        """
1241        return self._get_kap().get_disclosures(self._symbol)

Get recent KAP (Kamuyu Aydınlatma Platformu) disclosures for the stock.

Fetches directly from KAP - the official disclosure platform for publicly traded companies in Turkey.

Returns: DataFrame with columns: - Date: Disclosure date and time - Title: Disclosure headline - URL: Link to full disclosure on KAP

Examples:

stock = Ticker("THYAO") stock.news Date Title URL 0 29.12.2025 19:21:18 Haber ve Söylentilere İlişkin Açıklama https://www.kap.org.tr/tr/Bildirim/1530826 1 29.12.2025 16:11:36 Payların Geri Alınmasına İlişkin Bildirim https://www.kap.org.tr/tr/Bildirim/1530656

def get_news_content(self, disclosure_id: int | str) -> str | None:
1243    def get_news_content(self, disclosure_id: int | str) -> str | None:
1244        """
1245        Get full HTML content of a KAP disclosure by ID.
1246
1247        Args:
1248            disclosure_id: KAP disclosure ID from news DataFrame URL.
1249
1250        Returns:
1251            Raw HTML content or None if failed.
1252
1253        Examples:
1254            >>> stock = Ticker("THYAO")
1255            >>> html = stock.get_news_content(1530826)
1256        """
1257        return self._get_kap().get_disclosure_content(disclosure_id)

Get full HTML content of a KAP disclosure by ID.

Args: disclosure_id: KAP disclosure ID from news DataFrame URL.

Returns: Raw HTML content or None if failed.

Examples:

stock = Ticker("THYAO") html = stock.get_news_content(1530826)

calendar: pandas.core.frame.DataFrame
1259    @cached_property
1260    def calendar(self) -> pd.DataFrame:
1261        """
1262        Get expected disclosure calendar for the stock from KAP.
1263
1264        Returns upcoming expected disclosures like financial reports,
1265        annual reports, sustainability reports, and corporate governance reports.
1266
1267        Returns:
1268            DataFrame with columns:
1269            - StartDate: Expected disclosure window start
1270            - EndDate: Expected disclosure window end
1271            - Subject: Type of disclosure (e.g., "Finansal Rapor")
1272            - Period: Report period (e.g., "Yıllık", "3 Aylık")
1273            - Year: Fiscal year
1274
1275        Examples:
1276            >>> stock = Ticker("THYAO")
1277            >>> stock.calendar
1278                  StartDate       EndDate               Subject   Period  Year
1279            0  01.01.2026  11.03.2026       Finansal Rapor   Yıllık  2025
1280            1  01.01.2026  11.03.2026    Faaliyet Raporu  Yıllık  2025
1281            2  01.04.2026  11.05.2026       Finansal Rapor  3 Aylık  2026
1282        """
1283        return self._get_kap().get_calendar(self._symbol)

Get expected disclosure calendar for the stock from KAP.

Returns upcoming expected disclosures like financial reports, annual reports, sustainability reports, and corporate governance reports.

Returns: DataFrame with columns: - StartDate: Expected disclosure window start - EndDate: Expected disclosure window end - Subject: Type of disclosure (e.g., "Finansal Rapor") - Period: Report period (e.g., "Yıllık", "3 Aylık") - Year: Fiscal year

Examples:

stock = Ticker("THYAO") stock.calendar StartDate EndDate Subject Period Year 0 01.01.2026 11.03.2026 Finansal Rapor Yıllık 2025 1 01.01.2026 11.03.2026 Faaliyet Raporu Yıllık 2025 2 01.04.2026 11.05.2026 Finansal Rapor 3 Aylık 2026

isin: str | None
1285    @cached_property
1286    def isin(self) -> str | None:
1287        """
1288        Get ISIN (International Securities Identification Number) code.
1289
1290        ISIN is a 12-character alphanumeric code that uniquely identifies
1291        a security, standardized by ISO 6166.
1292
1293        Returns:
1294            ISIN code string (e.g., "TRATHYAO91M5") or None if not found.
1295
1296        Examples:
1297            >>> stock = Ticker("THYAO")
1298            >>> stock.isin
1299            'TRATHYAO91M5'
1300        """
1301        return self._get_isin_provider().get_isin(self._symbol)

Get ISIN (International Securities Identification Number) code.

ISIN is a 12-character alphanumeric code that uniquely identifies a security, standardized by ISO 6166.

Returns: ISIN code string (e.g., "TRATHYAO91M5") or None if not found.

Examples:

stock = Ticker("THYAO") stock.isin 'TRATHYAO91M5'

analyst_price_targets: dict[str, float | int | None]
1303    @cached_property
1304    def analyst_price_targets(self) -> dict[str, float | int | None]:
1305        """
1306        Get analyst price target data from hedeffiyat.com.tr.
1307
1308        Returns aggregated price target information from multiple analysts.
1309
1310        Returns:
1311            Dictionary with:
1312            - current: Current stock price
1313            - low: Lowest analyst target price
1314            - high: Highest analyst target price
1315            - mean: Average target price
1316            - median: Median target price
1317            - numberOfAnalysts: Number of analysts covering the stock
1318
1319        Examples:
1320            >>> stock = Ticker("THYAO")
1321            >>> stock.analyst_price_targets
1322            {'current': 268.5, 'low': 388.0, 'high': 580.0, 'mean': 474.49,
1323             'median': 465.0, 'numberOfAnalysts': 19}
1324        """
1325        return self._get_hedeffiyat().get_price_targets(self._symbol)

Get analyst price target data from hedeffiyat.com.tr.

Returns aggregated price target information from multiple analysts.

Returns: Dictionary with: - current: Current stock price - low: Lowest analyst target price - high: Highest analyst target price - mean: Average target price - median: Median target price - numberOfAnalysts: Number of analysts covering the stock

Examples:

stock = Ticker("THYAO") stock.analyst_price_targets {'current': 268.5, 'low': 388.0, 'high': 580.0, 'mean': 474.49, 'median': 465.0, 'numberOfAnalysts': 19}

etf_holders: pandas.core.frame.DataFrame
1327    @property
1328    def etf_holders(self) -> pd.DataFrame:
1329        """
1330        Get international ETFs that hold this stock.
1331
1332        Returns data from TradingView showing which ETFs hold this stock,
1333        including position value, weight, and ETF characteristics.
1334
1335        Returns:
1336            DataFrame with ETF holder information:
1337            - symbol: ETF ticker symbol
1338            - exchange: Exchange (AMEX, NASDAQ, LSE, etc.)
1339            - name: ETF full name
1340            - market_cap_usd: Position value in USD
1341            - holding_weight_pct: Weight percentage (0.09 = 0.09%)
1342            - issuer: ETF issuer (BlackRock, Vanguard, etc.)
1343            - management: Management style (Passive/Active)
1344            - focus: Investment focus (Total Market, Emerging Markets, etc.)
1345            - expense_ratio: Expense ratio (0.09 = 0.09%)
1346            - aum_usd: Total assets under management (USD)
1347            - price: Current ETF price
1348            - change_pct: Change percentage
1349
1350        Examples:
1351            >>> stock = Ticker("ASELS")
1352            >>> holders = stock.etf_holders
1353            >>> holders[['symbol', 'name', 'holding_weight_pct']].head()
1354               symbol                                      name  holding_weight_pct
1355            0    IEMG  iShares Core MSCI Emerging Markets ETF            0.090686
1356            1     VWO     Vanguard FTSE Emerging Markets ETF            0.060000
1357
1358            >>> print(f"Total ETFs: {len(holders)}")
1359            Total ETFs: 118
1360        """
1361        return self._get_etf_provider().get_etf_holders(self._symbol)

Get international ETFs that hold this stock.

Returns data from TradingView showing which ETFs hold this stock, including position value, weight, and ETF characteristics.

Returns: DataFrame with ETF holder information: - symbol: ETF ticker symbol - exchange: Exchange (AMEX, NASDAQ, LSE, etc.) - name: ETF full name - market_cap_usd: Position value in USD - holding_weight_pct: Weight percentage (0.09 = 0.09%) - issuer: ETF issuer (BlackRock, Vanguard, etc.) - management: Management style (Passive/Active) - focus: Investment focus (Total Market, Emerging Markets, etc.) - expense_ratio: Expense ratio (0.09 = 0.09%) - aum_usd: Total assets under management (USD) - price: Current ETF price - change_pct: Change percentage

Examples:

stock = Ticker("ASELS") holders = stock.etf_holders holders[['symbol', 'name', 'holding_weight_pct']].head() symbol name holding_weight_pct 0 IEMG iShares Core MSCI Emerging Markets ETF 0.090686 1 VWO Vanguard FTSE Emerging Markets ETF 0.060000

>>> print(f"Total ETFs: {len(holders)}")
Total ETFs: 118
earnings_dates: pandas.core.frame.DataFrame
1363    @cached_property
1364    def earnings_dates(self) -> pd.DataFrame:
1365        """
1366        Get upcoming earnings announcement dates.
1367
1368        Derived from KAP calendar, showing expected financial report dates.
1369        Compatible with yfinance earnings_dates format.
1370
1371        Returns:
1372            DataFrame with index as Earnings Date and columns:
1373            - EPS Estimate: Always None (not available for BIST)
1374            - Reported EPS: Always None (not available for BIST)
1375            - Surprise (%): Always None (not available for BIST)
1376
1377        Examples:
1378            >>> stock = Ticker("THYAO")
1379            >>> stock.earnings_dates
1380                            EPS Estimate  Reported EPS  Surprise(%)
1381            Earnings Date
1382            2026-03-11           None          None         None
1383            2026-05-11           None          None         None
1384        """
1385        cal = self.calendar
1386        if cal.empty:
1387            return pd.DataFrame(
1388                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1389            )
1390
1391        # Filter for financial reports only
1392        financial_reports = cal[
1393            cal["Subject"].str.contains("Finansal Rapor", case=False, na=False)
1394        ]
1395
1396        if financial_reports.empty:
1397            return pd.DataFrame(
1398                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1399            )
1400
1401        # Use EndDate as the earnings date (latest expected date)
1402        earnings_dates = []
1403        for _, row in financial_reports.iterrows():
1404            end_date = row.get("EndDate", "")
1405            if end_date:
1406                try:
1407                    # Parse Turkish date format (DD.MM.YYYY)
1408                    parsed = datetime.strptime(end_date, "%d.%m.%Y")
1409                    earnings_dates.append(parsed)
1410                except ValueError:
1411                    continue
1412
1413        if not earnings_dates:
1414            return pd.DataFrame(
1415                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1416            )
1417
1418        result = pd.DataFrame(
1419            {
1420                "EPS Estimate": [None] * len(earnings_dates),
1421                "Reported EPS": [None] * len(earnings_dates),
1422                "Surprise(%)": [None] * len(earnings_dates),
1423            },
1424            index=pd.DatetimeIndex(earnings_dates, name="Earnings Date"),
1425        )
1426        result = result.sort_index()
1427        return result

Get upcoming earnings announcement dates.

Derived from KAP calendar, showing expected financial report dates. Compatible with yfinance earnings_dates format.

Returns: DataFrame with index as Earnings Date and columns: - EPS Estimate: Always None (not available for BIST) - Reported EPS: Always None (not available for BIST) - Surprise (%): Always None (not available for BIST)

Examples:

stock = Ticker("THYAO") stock.earnings_dates EPS Estimate Reported EPS Surprise(%) Earnings Date 2026-03-11 None None None 2026-05-11 None None None

class Tickers:
 12class Tickers:
 13    """
 14    Container for multiple Ticker objects.
 15
 16    Examples:
 17        >>> import borsapy as bp
 18        >>> tickers = bp.Tickers("THYAO GARAN AKBNK")
 19        >>> tickers.tickers["THYAO"].info
 20        >>> tickers.symbols
 21        ['THYAO', 'GARAN', 'AKBNK']
 22
 23        >>> tickers = bp.Tickers(["THYAO", "GARAN", "AKBNK"])
 24        >>> for symbol, ticker in tickers:
 25        ...     print(symbol, ticker.info['last'])
 26    """
 27
 28    def __init__(self, symbols: str | list[str]):
 29        """
 30        Initialize Tickers with multiple symbols.
 31
 32        Args:
 33            symbols: Space-separated string or list of symbols.
 34                     Example: "THYAO GARAN AKBNK" or ["THYAO", "GARAN", "AKBNK"]
 35        """
 36        if isinstance(symbols, str):
 37            self._symbols = [s.strip().upper() for s in symbols.split() if s.strip()]
 38        else:
 39            self._symbols = [s.strip().upper() for s in symbols if s.strip()]
 40
 41        self._tickers: dict[str, Ticker] = {}
 42        for symbol in self._symbols:
 43            self._tickers[symbol] = Ticker(symbol)
 44
 45    @property
 46    def symbols(self) -> list[str]:
 47        """Return list of symbols."""
 48        return self._symbols.copy()
 49
 50    @property
 51    def tickers(self) -> dict[str, Ticker]:
 52        """Return dictionary of Ticker objects keyed by symbol."""
 53        return self._tickers
 54
 55    def history(
 56        self,
 57        period: str = "1mo",
 58        interval: str = "1d",
 59        start: datetime | str | None = None,
 60        end: datetime | str | None = None,
 61        group_by: str = "column",
 62    ) -> pd.DataFrame:
 63        """
 64        Get historical data for all tickers.
 65
 66        Args:
 67            period: Data period (1d, 5d, 1mo, 3mo, 6mo, 1y, etc.)
 68            interval: Data interval (1d, 1wk, 1mo)
 69            start: Start date
 70            end: End date
 71            group_by: How to group columns ('column' or 'ticker')
 72
 73        Returns:
 74            DataFrame with multi-level columns.
 75        """
 76        return download(
 77            self._symbols,
 78            period=period,
 79            interval=interval,
 80            start=start,
 81            end=end,
 82            group_by=group_by,
 83        )
 84
 85    def __iter__(self):
 86        """Iterate over (symbol, ticker) pairs."""
 87        return iter(self._tickers.items())
 88
 89    def __len__(self) -> int:
 90        """Return number of tickers."""
 91        return len(self._tickers)
 92
 93    def __getitem__(self, symbol: str) -> Ticker:
 94        """Get ticker by symbol."""
 95        symbol = symbol.upper()
 96        if symbol not in self._tickers:
 97            raise KeyError(f"Symbol not found: {symbol}")
 98        return self._tickers[symbol]
 99
100    def __repr__(self) -> str:
101        return f"Tickers({self._symbols})"

Container for multiple Ticker objects.

Examples:

import borsapy as bp tickers = bp.Tickers("THYAO GARAN AKBNK") tickers.tickers["THYAO"].info tickers.symbols ['THYAO', 'GARAN', 'AKBNK']

>>> tickers = bp.Tickers(["THYAO", "GARAN", "AKBNK"])
>>> for symbol, ticker in tickers:
...     print(symbol, ticker.info['last'])
Tickers(symbols: str | list[str])
28    def __init__(self, symbols: str | list[str]):
29        """
30        Initialize Tickers with multiple symbols.
31
32        Args:
33            symbols: Space-separated string or list of symbols.
34                     Example: "THYAO GARAN AKBNK" or ["THYAO", "GARAN", "AKBNK"]
35        """
36        if isinstance(symbols, str):
37            self._symbols = [s.strip().upper() for s in symbols.split() if s.strip()]
38        else:
39            self._symbols = [s.strip().upper() for s in symbols if s.strip()]
40
41        self._tickers: dict[str, Ticker] = {}
42        for symbol in self._symbols:
43            self._tickers[symbol] = Ticker(symbol)

Initialize Tickers with multiple symbols.

Args: symbols: Space-separated string or list of symbols. Example: "THYAO GARAN AKBNK" or ["THYAO", "GARAN", "AKBNK"]

symbols: list[str]
45    @property
46    def symbols(self) -> list[str]:
47        """Return list of symbols."""
48        return self._symbols.copy()

Return list of symbols.

tickers: dict[str, Ticker]
50    @property
51    def tickers(self) -> dict[str, Ticker]:
52        """Return dictionary of Ticker objects keyed by symbol."""
53        return self._tickers

Return dictionary of Ticker objects keyed by symbol.

def history( self, period: str = '1mo', interval: str = '1d', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None, group_by: str = 'column') -> pandas.core.frame.DataFrame:
55    def history(
56        self,
57        period: str = "1mo",
58        interval: str = "1d",
59        start: datetime | str | None = None,
60        end: datetime | str | None = None,
61        group_by: str = "column",
62    ) -> pd.DataFrame:
63        """
64        Get historical data for all tickers.
65
66        Args:
67            period: Data period (1d, 5d, 1mo, 3mo, 6mo, 1y, etc.)
68            interval: Data interval (1d, 1wk, 1mo)
69            start: Start date
70            end: End date
71            group_by: How to group columns ('column' or 'ticker')
72
73        Returns:
74            DataFrame with multi-level columns.
75        """
76        return download(
77            self._symbols,
78            period=period,
79            interval=interval,
80            start=start,
81            end=end,
82            group_by=group_by,
83        )

Get historical data for all tickers.

Args: period: Data period (1d, 5d, 1mo, 3mo, 6mo, 1y, etc.) interval: Data interval (1d, 1wk, 1mo) start: Start date end: End date group_by: How to group columns ('column' or 'ticker')

Returns: DataFrame with multi-level columns.

class FX(borsapy.technical.TechnicalMixin, borsapy.twitter.TwitterMixin):
 74class FX(TechnicalMixin, TwitterMixin):
 75    """
 76    A yfinance-like interface for forex and commodity data.
 77
 78    Supported assets:
 79    - Currencies: USD, EUR, GBP, JPY, CHF, CAD, AUD (+ 58 more via canlidoviz)
 80    - Precious Metals: gram-altin, gumus, ons-altin, gram-platin, XAG-USD, XPT-USD, XPD-USD
 81    - Energy: BRENT
 82
 83    Examples:
 84        >>> import borsapy as bp
 85        >>> usd = bp.FX("USD")
 86        >>> usd.current
 87        {'symbol': 'USD', 'last': 34.85, ...}
 88        >>> usd.history(period="1mo")
 89                         Open    High     Low   Close
 90        Date
 91        2024-12-01    34.50   34.80   34.40   34.75
 92        ...
 93
 94        >>> gold = bp.FX("gram-altin")
 95        >>> gold.current
 96        {'symbol': 'gram-altin', 'last': 2850.50, ...}
 97    """
 98
 99    def __init__(self, asset: str):
100        """
101        Initialize an FX object.
102
103        Args:
104            asset: Asset code (USD, EUR, gram-altin, BRENT, etc.)
105        """
106        self._asset = asset
107        self._canlidoviz = get_canlidoviz_provider()
108        self._dovizcom = get_dovizcom_provider()
109        self._tradingview = get_tradingview_provider()
110        self._current_cache: dict[str, Any] | None = None
111
112    def _get_tweet_query(self) -> str:
113        return _build_fx_query(self._asset)
114
115    def _use_canlidoviz(self) -> bool:
116        """Check if canlidoviz should be used for this asset."""
117        asset_upper = self._asset.upper()
118        # Currencies
119        if asset_upper in self._canlidoviz.CURRENCY_IDS:
120            return True
121        # Metals supported by canlidoviz (TRY prices)
122        if self._asset in self._canlidoviz.METAL_IDS:
123            return True
124        # Energy supported by canlidoviz (USD prices)
125        if asset_upper in self._canlidoviz.ENERGY_IDS:
126            return True
127        # Commodities supported by canlidoviz (USD prices)
128        if asset_upper in self._canlidoviz.COMMODITY_IDS:
129            return True
130        return False
131
132    def _get_tradingview_symbol(self) -> tuple[str, str] | None:
133        """Get TradingView exchange and symbol for this asset.
134
135        Returns:
136            Tuple of (exchange, symbol) or None if not supported.
137        """
138        asset_upper = self._asset.upper()
139
140        # Check currency map first
141        if asset_upper in TV_CURRENCY_MAP:
142            return TV_CURRENCY_MAP[asset_upper]
143
144        # Check commodity map
145        if self._asset in TV_COMMODITY_MAP:
146            return TV_COMMODITY_MAP[self._asset]
147        if asset_upper in TV_COMMODITY_MAP:
148            return TV_COMMODITY_MAP[asset_upper]
149
150        return None
151
152    @property
153    def asset(self) -> str:
154        """Return the asset code."""
155        return self._asset
156
157    @property
158    def symbol(self) -> str:
159        """Return the asset code (alias for asset)."""
160        return self._asset
161
162    @property
163    def current(self) -> dict[str, Any]:
164        """
165        Get current price information.
166
167        Returns:
168            Dictionary with current market data:
169            - symbol: Asset code
170            - last: Last price
171            - open: Opening price
172            - high: Day high
173            - low: Day low
174            - update_time: Last update timestamp
175        """
176        if self._current_cache is None:
177            if self._use_canlidoviz():
178                self._current_cache = self._canlidoviz.get_current(self._asset)
179            else:
180                try:
181                    self._current_cache = self._dovizcom.get_current(self._asset)
182                except Exception:
183                    # Fallback to bank_rates for currencies not supported by APIs
184                    self._current_cache = self._current_from_bank_rates()
185        return self._current_cache
186
187    def _current_from_bank_rates(self) -> dict[str, Any]:
188        """Calculate current price from bank rates as fallback."""
189        from datetime import datetime
190
191        rates = self._dovizcom.get_bank_rates(self._asset)
192        if rates.empty:
193            raise ValueError(f"No data available for {self._asset}")
194
195        # Calculate average mid price from all banks
196        mids = (rates["buy"] + rates["sell"]) / 2
197        avg_mid = float(mids.mean())
198
199        return {
200            "symbol": self._asset,
201            "last": avg_mid,
202            "open": avg_mid,
203            "high": float(rates["sell"].max()),
204            "low": float(rates["buy"].min()),
205            "update_time": datetime.now(),
206            "source": "bank_rates_avg",
207        }
208
209    @property
210    def info(self) -> dict[str, Any]:
211        """Alias for current property (yfinance compatibility)."""
212        return self.current
213
214    @property
215    def bank_rates(self) -> pd.DataFrame:
216        """
217        Get exchange rates from all banks.
218
219        Returns:
220            DataFrame with columns: bank, bank_name, currency, buy, sell, spread
221
222        Examples:
223            >>> usd = FX("USD")
224            >>> usd.bank_rates
225                      bank        bank_name currency      buy     sell  spread
226            0       akbank           Akbank      USD  41.6610  44.1610    5.99
227            1      garanti     Garanti BBVA      USD  41.7000  44.2000    5.99
228            ...
229        """
230        return self._dovizcom.get_bank_rates(self._asset)
231
232    def bank_rate(self, bank: str) -> dict[str, Any]:
233        """
234        Get exchange rate from a specific bank.
235
236        Args:
237            bank: Bank code (akbank, garanti, isbank, ziraat, etc.)
238
239        Returns:
240            Dictionary with keys: bank, currency, buy, sell, spread
241
242        Examples:
243            >>> usd = FX("USD")
244            >>> usd.bank_rate("akbank")
245            {'bank': 'akbank', 'currency': 'USD', 'buy': 41.6610, 'sell': 44.1610, 'spread': 5.99}
246        """
247        return self._dovizcom.get_bank_rates(self._asset, bank=bank)
248
249    @staticmethod
250    def banks() -> list[str]:
251        """
252        Get list of supported banks.
253
254        Returns:
255            List of bank codes.
256
257        Examples:
258            >>> FX.banks()
259            ['akbank', 'albaraka', 'alternatifbank', 'anadolubank', ...]
260        """
261        from borsapy._providers.dovizcom import get_dovizcom_provider
262
263        return get_dovizcom_provider().get_banks()
264
265    @property
266    def institution_rates(self) -> pd.DataFrame:
267        """
268        Get precious metal rates from all institutions (kuyumcular, bankalar).
269
270        Only available for precious metals: gram-altin, gram-gumus, ons-altin,
271        gram-platin
272
273        Returns:
274            DataFrame with columns: institution, institution_name, asset, buy, sell, spread
275
276        Examples:
277            >>> gold = FX("gram-altin")
278            >>> gold.institution_rates
279                   institution  institution_name       asset      buy     sell  spread
280            0      altinkaynak       Altınkaynak  gram-altin  6315.00  6340.00    0.40
281            1           akbank            Akbank  gram-altin  6310.00  6330.00    0.32
282            ...
283        """
284        return self._dovizcom.get_metal_institution_rates(self._asset)
285
286    def institution_rate(self, institution: str) -> dict[str, Any]:
287        """
288        Get precious metal rate from a specific institution.
289
290        Args:
291            institution: Institution slug (kapalicarsi, altinkaynak, akbank, etc.)
292
293        Returns:
294            Dictionary with keys: institution, institution_name, asset, buy, sell, spread
295
296        Examples:
297            >>> gold = FX("gram-altin")
298            >>> gold.institution_rate("akbank")
299            {'institution': 'akbank', 'institution_name': 'Akbank', 'asset': 'gram-altin',
300             'buy': 6310.00, 'sell': 6330.00, 'spread': 0.32}
301        """
302        return self._dovizcom.get_metal_institution_rates(self._asset, institution=institution)
303
304    @staticmethod
305    def metal_institutions() -> list[str]:
306        """
307        Get list of supported precious metal assets for institution rates.
308
309        Returns:
310            List of asset codes that support institution_rates.
311
312        Examples:
313            >>> FX.metal_institutions()
314            ['gram-altin', 'gram-gumus', 'gram-platin', 'ons-altin']
315        """
316        from borsapy._providers.dovizcom import get_dovizcom_provider
317
318        return get_dovizcom_provider().get_metal_institutions()
319
320    def history(
321        self,
322        period: str = "1mo",
323        interval: str = "1d",
324        start: datetime | str | None = None,
325        end: datetime | str | None = None,
326    ) -> pd.DataFrame:
327        """
328        Get historical OHLC data.
329
330        Args:
331            period: How much data to fetch. Valid periods:
332                    1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, max.
333                    Ignored if start is provided.
334            interval: Data interval. Valid intervals:
335                    1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk, 1mo.
336                    Note: Intraday intervals (1m-4h) use TradingView.
337                    Daily and above use canlidoviz/dovizcom.
338            start: Start date (string or datetime).
339            end: End date (string or datetime). Defaults to today.
340
341        Returns:
342            DataFrame with columns: Open, High, Low, Close, Volume.
343            Index is the Date.
344
345        Examples:
346            >>> fx = FX("USD")
347            >>> fx.history(period="1mo")  # Last month daily
348            >>> fx.history(period="1d", interval="1m")  # Today's minute data
349            >>> fx.history(period="5d", interval="1h")  # 5 days hourly
350            >>> fx.history(start="2024-01-01", end="2024-06-30")  # Date range
351        """
352        start_dt = self._parse_date(start) if start else None
353        end_dt = self._parse_date(end) if end else None
354
355        # Use TradingView for intraday intervals
356        intraday_intervals = ("1m", "5m", "15m", "30m", "1h", "4h")
357        if interval in intraday_intervals:
358            tv_info = self._get_tradingview_symbol()
359            if tv_info is None:
360                raise ValueError(
361                    f"Intraday data not available for {self._asset}. "
362                    f"Supported currencies: {list(TV_CURRENCY_MAP.keys())}"
363                )
364
365            exchange, symbol = tv_info
366            return self._tradingview.get_history(
367                symbol=symbol,
368                period=period,
369                interval=interval,
370                start=start_dt,
371                end=end_dt,
372                exchange=exchange,
373            )
374
375        # Use canlidoviz/dovizcom for daily and above
376        if self._use_canlidoviz():
377            return self._canlidoviz.get_history(
378                asset=self._asset,
379                period=period,
380                start=start_dt,
381                end=end_dt,
382            )
383        else:
384            return self._dovizcom.get_history(
385                asset=self._asset,
386                period=period,
387                start=start_dt,
388                end=end_dt,
389            )
390
391    def institution_history(
392        self,
393        institution: str,
394        period: str = "1mo",
395        start: datetime | str | None = None,
396        end: datetime | str | None = None,
397    ) -> pd.DataFrame:
398        """
399        Get historical OHLC data from a specific institution.
400
401        Supports both precious metals and currencies.
402
403        Args:
404            institution: Institution slug (akbank, kapalicarsi, harem, etc.)
405            period: How much data to fetch. Valid periods:
406                    1d, 5d, 1mo, 3mo, 6mo, 1y.
407                    Ignored if start is provided.
408            start: Start date (string or datetime).
409            end: End date (string or datetime). Defaults to today.
410
411        Returns:
412            DataFrame with columns: Open, High, Low, Close.
413            Index is the Date.
414            Note: Banks typically return only Close values (Open/High/Low = 0).
415
416        Examples:
417            >>> # Metal history
418            >>> gold = FX("gram-altin")
419            >>> gold.institution_history("akbank", period="1mo")
420            >>> gold.institution_history("kapalicarsi", start="2024-01-01")
421
422            >>> # Currency history
423            >>> usd = FX("USD")
424            >>> usd.institution_history("akbank", period="1mo")
425            >>> usd.institution_history("garanti-bbva", period="5d")
426        """
427        start_dt = self._parse_date(start) if start else None
428        end_dt = self._parse_date(end) if end else None
429
430        # Use canlidoviz for currencies and precious metals (bank-specific rates)
431        asset_upper = self._asset.upper()
432        use_canlidoviz = (
433            asset_upper in self._canlidoviz.CURRENCY_IDS
434            or self._asset in ("gram-altin", "gumus", "gram-platin")
435        )
436
437        if use_canlidoviz:
438            # Check if canlidoviz has bank ID for this asset
439            try:
440                return self._canlidoviz.get_history(
441                    asset=self._asset,
442                    period=period,
443                    start=start_dt,
444                    end=end_dt,
445                    institution=institution,
446                )
447            except Exception:
448                # Fall back to dovizcom if canlidoviz doesn't support this bank
449                pass
450
451        # Use dovizcom for other metals and unsupported banks
452        return self._dovizcom.get_institution_history(
453            asset=self._asset,
454            institution=institution,
455            period=period,
456            start=start_dt,
457            end=end_dt,
458        )
459
460    def _parse_date(self, date: str | datetime) -> datetime:
461        """Parse a date string to datetime."""
462        if isinstance(date, datetime):
463            return date
464        for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"]:
465            try:
466                return datetime.strptime(date, fmt)
467            except ValueError:
468                continue
469        raise ValueError(f"Could not parse date: {date}")
470
471    def _get_ta_symbol_info(self) -> tuple[str, str]:
472        """Get TradingView symbol and screener for TA signals.
473
474        Returns:
475            Tuple of (tv_symbol, screener) for TradingView Scanner API.
476
477        Raises:
478            NotImplementedError: If TA signals not available for this asset.
479        """
480        tv_info = self._get_tradingview_symbol()
481        if tv_info is None:
482            raise NotImplementedError(
483                f"TA signals not available for {self._asset}. "
484                f"Supported currencies: {list(TV_CURRENCY_MAP.keys())}. "
485                f"Supported commodities: {list(TV_COMMODITY_MAP.keys())}."
486            )
487        exchange, symbol = tv_info
488        return (f"{exchange}:{symbol}", "forex")
489
490    def __repr__(self) -> str:
491        return f"FX('{self._asset}')"

A yfinance-like interface for forex and commodity data.

Supported assets:

  • Currencies: USD, EUR, GBP, JPY, CHF, CAD, AUD (+ 58 more via canlidoviz)
  • Precious Metals: gram-altin, gumus, ons-altin, gram-platin, XAG-USD, XPT-USD, XPD-USD
  • Energy: BRENT

Examples:

import borsapy as bp usd = bp.FX("USD") usd.current {'symbol': 'USD', 'last': 34.85, ...} usd.history(period="1mo") Open High Low Close Date 2024-12-01 34.50 34.80 34.40 34.75 ...

>>> gold = bp.FX("gram-altin")
>>> gold.current
{'symbol': 'gram-altin', 'last': 2850.50, ...}
FX(asset: str)
 99    def __init__(self, asset: str):
100        """
101        Initialize an FX object.
102
103        Args:
104            asset: Asset code (USD, EUR, gram-altin, BRENT, etc.)
105        """
106        self._asset = asset
107        self._canlidoviz = get_canlidoviz_provider()
108        self._dovizcom = get_dovizcom_provider()
109        self._tradingview = get_tradingview_provider()
110        self._current_cache: dict[str, Any] | None = None

Initialize an FX object.

Args: asset: Asset code (USD, EUR, gram-altin, BRENT, etc.)

asset: str
152    @property
153    def asset(self) -> str:
154        """Return the asset code."""
155        return self._asset

Return the asset code.

symbol: str
157    @property
158    def symbol(self) -> str:
159        """Return the asset code (alias for asset)."""
160        return self._asset

Return the asset code (alias for asset).

current: dict[str, typing.Any]
162    @property
163    def current(self) -> dict[str, Any]:
164        """
165        Get current price information.
166
167        Returns:
168            Dictionary with current market data:
169            - symbol: Asset code
170            - last: Last price
171            - open: Opening price
172            - high: Day high
173            - low: Day low
174            - update_time: Last update timestamp
175        """
176        if self._current_cache is None:
177            if self._use_canlidoviz():
178                self._current_cache = self._canlidoviz.get_current(self._asset)
179            else:
180                try:
181                    self._current_cache = self._dovizcom.get_current(self._asset)
182                except Exception:
183                    # Fallback to bank_rates for currencies not supported by APIs
184                    self._current_cache = self._current_from_bank_rates()
185        return self._current_cache

Get current price information.

Returns: Dictionary with current market data: - symbol: Asset code - last: Last price - open: Opening price - high: Day high - low: Day low - update_time: Last update timestamp

info: dict[str, typing.Any]
209    @property
210    def info(self) -> dict[str, Any]:
211        """Alias for current property (yfinance compatibility)."""
212        return self.current

Alias for current property (yfinance compatibility).

bank_rates: pandas.core.frame.DataFrame
214    @property
215    def bank_rates(self) -> pd.DataFrame:
216        """
217        Get exchange rates from all banks.
218
219        Returns:
220            DataFrame with columns: bank, bank_name, currency, buy, sell, spread
221
222        Examples:
223            >>> usd = FX("USD")
224            >>> usd.bank_rates
225                      bank        bank_name currency      buy     sell  spread
226            0       akbank           Akbank      USD  41.6610  44.1610    5.99
227            1      garanti     Garanti BBVA      USD  41.7000  44.2000    5.99
228            ...
229        """
230        return self._dovizcom.get_bank_rates(self._asset)

Get exchange rates from all banks.

Returns: DataFrame with columns: bank, bank_name, currency, buy, sell, spread

Examples:

usd = FX("USD") usd.bank_rates bank bank_name currency buy sell spread 0 akbank Akbank USD 41.6610 44.1610 5.99 1 garanti Garanti BBVA USD 41.7000 44.2000 5.99 ...

def bank_rate(self, bank: str) -> dict[str, typing.Any]:
232    def bank_rate(self, bank: str) -> dict[str, Any]:
233        """
234        Get exchange rate from a specific bank.
235
236        Args:
237            bank: Bank code (akbank, garanti, isbank, ziraat, etc.)
238
239        Returns:
240            Dictionary with keys: bank, currency, buy, sell, spread
241
242        Examples:
243            >>> usd = FX("USD")
244            >>> usd.bank_rate("akbank")
245            {'bank': 'akbank', 'currency': 'USD', 'buy': 41.6610, 'sell': 44.1610, 'spread': 5.99}
246        """
247        return self._dovizcom.get_bank_rates(self._asset, bank=bank)

Get exchange rate from a specific bank.

Args: bank: Bank code (akbank, garanti, isbank, ziraat, etc.)

Returns: Dictionary with keys: bank, currency, buy, sell, spread

Examples:

usd = FX("USD") usd.bank_rate("akbank") {'bank': 'akbank', 'currency': 'USD', 'buy': 41.6610, 'sell': 44.1610, 'spread': 5.99}

@staticmethod
def banks() -> list[str]:
249    @staticmethod
250    def banks() -> list[str]:
251        """
252        Get list of supported banks.
253
254        Returns:
255            List of bank codes.
256
257        Examples:
258            >>> FX.banks()
259            ['akbank', 'albaraka', 'alternatifbank', 'anadolubank', ...]
260        """
261        from borsapy._providers.dovizcom import get_dovizcom_provider
262
263        return get_dovizcom_provider().get_banks()

Get list of supported banks.

Returns: List of bank codes.

Examples:

FX.banks() ['akbank', 'albaraka', 'alternatifbank', 'anadolubank', ...]

institution_rates: pandas.core.frame.DataFrame
265    @property
266    def institution_rates(self) -> pd.DataFrame:
267        """
268        Get precious metal rates from all institutions (kuyumcular, bankalar).
269
270        Only available for precious metals: gram-altin, gram-gumus, ons-altin,
271        gram-platin
272
273        Returns:
274            DataFrame with columns: institution, institution_name, asset, buy, sell, spread
275
276        Examples:
277            >>> gold = FX("gram-altin")
278            >>> gold.institution_rates
279                   institution  institution_name       asset      buy     sell  spread
280            0      altinkaynak       Altınkaynak  gram-altin  6315.00  6340.00    0.40
281            1           akbank            Akbank  gram-altin  6310.00  6330.00    0.32
282            ...
283        """
284        return self._dovizcom.get_metal_institution_rates(self._asset)

Get precious metal rates from all institutions (kuyumcular, bankalar).

Only available for precious metals: gram-altin, gram-gumus, ons-altin, gram-platin

Returns: DataFrame with columns: institution, institution_name, asset, buy, sell, spread

Examples:

gold = FX("gram-altin") gold.institution_rates institution institution_name asset buy sell spread 0 altinkaynak Altınkaynak gram-altin 6315.00 6340.00 0.40 1 akbank Akbank gram-altin 6310.00 6330.00 0.32 ...

def institution_rate(self, institution: str) -> dict[str, typing.Any]:
286    def institution_rate(self, institution: str) -> dict[str, Any]:
287        """
288        Get precious metal rate from a specific institution.
289
290        Args:
291            institution: Institution slug (kapalicarsi, altinkaynak, akbank, etc.)
292
293        Returns:
294            Dictionary with keys: institution, institution_name, asset, buy, sell, spread
295
296        Examples:
297            >>> gold = FX("gram-altin")
298            >>> gold.institution_rate("akbank")
299            {'institution': 'akbank', 'institution_name': 'Akbank', 'asset': 'gram-altin',
300             'buy': 6310.00, 'sell': 6330.00, 'spread': 0.32}
301        """
302        return self._dovizcom.get_metal_institution_rates(self._asset, institution=institution)

Get precious metal rate from a specific institution.

Args: institution: Institution slug (kapalicarsi, altinkaynak, akbank, etc.)

Returns: Dictionary with keys: institution, institution_name, asset, buy, sell, spread

Examples:

gold = FX("gram-altin") gold.institution_rate("akbank") {'institution': 'akbank', 'institution_name': 'Akbank', 'asset': 'gram-altin', 'buy': 6310.00, 'sell': 6330.00, 'spread': 0.32}

@staticmethod
def metal_institutions() -> list[str]:
304    @staticmethod
305    def metal_institutions() -> list[str]:
306        """
307        Get list of supported precious metal assets for institution rates.
308
309        Returns:
310            List of asset codes that support institution_rates.
311
312        Examples:
313            >>> FX.metal_institutions()
314            ['gram-altin', 'gram-gumus', 'gram-platin', 'ons-altin']
315        """
316        from borsapy._providers.dovizcom import get_dovizcom_provider
317
318        return get_dovizcom_provider().get_metal_institutions()

Get list of supported precious metal assets for institution rates.

Returns: List of asset codes that support institution_rates.

Examples:

FX.metal_institutions() ['gram-altin', 'gram-gumus', 'gram-platin', 'ons-altin']

def history( self, period: str = '1mo', interval: str = '1d', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None) -> pandas.core.frame.DataFrame:
320    def history(
321        self,
322        period: str = "1mo",
323        interval: str = "1d",
324        start: datetime | str | None = None,
325        end: datetime | str | None = None,
326    ) -> pd.DataFrame:
327        """
328        Get historical OHLC data.
329
330        Args:
331            period: How much data to fetch. Valid periods:
332                    1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, max.
333                    Ignored if start is provided.
334            interval: Data interval. Valid intervals:
335                    1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk, 1mo.
336                    Note: Intraday intervals (1m-4h) use TradingView.
337                    Daily and above use canlidoviz/dovizcom.
338            start: Start date (string or datetime).
339            end: End date (string or datetime). Defaults to today.
340
341        Returns:
342            DataFrame with columns: Open, High, Low, Close, Volume.
343            Index is the Date.
344
345        Examples:
346            >>> fx = FX("USD")
347            >>> fx.history(period="1mo")  # Last month daily
348            >>> fx.history(period="1d", interval="1m")  # Today's minute data
349            >>> fx.history(period="5d", interval="1h")  # 5 days hourly
350            >>> fx.history(start="2024-01-01", end="2024-06-30")  # Date range
351        """
352        start_dt = self._parse_date(start) if start else None
353        end_dt = self._parse_date(end) if end else None
354
355        # Use TradingView for intraday intervals
356        intraday_intervals = ("1m", "5m", "15m", "30m", "1h", "4h")
357        if interval in intraday_intervals:
358            tv_info = self._get_tradingview_symbol()
359            if tv_info is None:
360                raise ValueError(
361                    f"Intraday data not available for {self._asset}. "
362                    f"Supported currencies: {list(TV_CURRENCY_MAP.keys())}"
363                )
364
365            exchange, symbol = tv_info
366            return self._tradingview.get_history(
367                symbol=symbol,
368                period=period,
369                interval=interval,
370                start=start_dt,
371                end=end_dt,
372                exchange=exchange,
373            )
374
375        # Use canlidoviz/dovizcom for daily and above
376        if self._use_canlidoviz():
377            return self._canlidoviz.get_history(
378                asset=self._asset,
379                period=period,
380                start=start_dt,
381                end=end_dt,
382            )
383        else:
384            return self._dovizcom.get_history(
385                asset=self._asset,
386                period=period,
387                start=start_dt,
388                end=end_dt,
389            )

Get historical OHLC data.

Args: period: How much data to fetch. Valid periods: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, max. Ignored if start is provided. interval: Data interval. Valid intervals: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk, 1mo. Note: Intraday intervals (1m-4h) use TradingView. Daily and above use canlidoviz/dovizcom. start: Start date (string or datetime). end: End date (string or datetime). Defaults to today.

Returns: DataFrame with columns: Open, High, Low, Close, Volume. Index is the Date.

Examples:

fx = FX("USD") fx.history(period="1mo") # Last month daily fx.history(period="1d", interval="1m") # Today's minute data fx.history(period="5d", interval="1h") # 5 days hourly fx.history(start="2024-01-01", end="2024-06-30") # Date range

def institution_history( self, institution: str, period: str = '1mo', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None) -> pandas.core.frame.DataFrame:
391    def institution_history(
392        self,
393        institution: str,
394        period: str = "1mo",
395        start: datetime | str | None = None,
396        end: datetime | str | None = None,
397    ) -> pd.DataFrame:
398        """
399        Get historical OHLC data from a specific institution.
400
401        Supports both precious metals and currencies.
402
403        Args:
404            institution: Institution slug (akbank, kapalicarsi, harem, etc.)
405            period: How much data to fetch. Valid periods:
406                    1d, 5d, 1mo, 3mo, 6mo, 1y.
407                    Ignored if start is provided.
408            start: Start date (string or datetime).
409            end: End date (string or datetime). Defaults to today.
410
411        Returns:
412            DataFrame with columns: Open, High, Low, Close.
413            Index is the Date.
414            Note: Banks typically return only Close values (Open/High/Low = 0).
415
416        Examples:
417            >>> # Metal history
418            >>> gold = FX("gram-altin")
419            >>> gold.institution_history("akbank", period="1mo")
420            >>> gold.institution_history("kapalicarsi", start="2024-01-01")
421
422            >>> # Currency history
423            >>> usd = FX("USD")
424            >>> usd.institution_history("akbank", period="1mo")
425            >>> usd.institution_history("garanti-bbva", period="5d")
426        """
427        start_dt = self._parse_date(start) if start else None
428        end_dt = self._parse_date(end) if end else None
429
430        # Use canlidoviz for currencies and precious metals (bank-specific rates)
431        asset_upper = self._asset.upper()
432        use_canlidoviz = (
433            asset_upper in self._canlidoviz.CURRENCY_IDS
434            or self._asset in ("gram-altin", "gumus", "gram-platin")
435        )
436
437        if use_canlidoviz:
438            # Check if canlidoviz has bank ID for this asset
439            try:
440                return self._canlidoviz.get_history(
441                    asset=self._asset,
442                    period=period,
443                    start=start_dt,
444                    end=end_dt,
445                    institution=institution,
446                )
447            except Exception:
448                # Fall back to dovizcom if canlidoviz doesn't support this bank
449                pass
450
451        # Use dovizcom for other metals and unsupported banks
452        return self._dovizcom.get_institution_history(
453            asset=self._asset,
454            institution=institution,
455            period=period,
456            start=start_dt,
457            end=end_dt,
458        )

Get historical OHLC data from a specific institution.

Supports both precious metals and currencies.

Args: institution: Institution slug (akbank, kapalicarsi, harem, etc.) period: How much data to fetch. Valid periods: 1d, 5d, 1mo, 3mo, 6mo, 1y. Ignored if start is provided. start: Start date (string or datetime). end: End date (string or datetime). Defaults to today.

Returns: DataFrame with columns: Open, High, Low, Close. Index is the Date. Note: Banks typically return only Close values (Open/High/Low = 0).

Examples:

Metal history

gold = FX("gram-altin") gold.institution_history("akbank", period="1mo") gold.institution_history("kapalicarsi", start="2024-01-01")

>>> # Currency history
>>> usd = FX("USD")
>>> usd.institution_history("akbank", period="1mo")
>>> usd.institution_history("garanti-bbva", period="5d")
class Crypto(borsapy.technical.TechnicalMixin, borsapy.twitter.TwitterMixin):
 14class Crypto(TechnicalMixin, TwitterMixin):
 15    """
 16    A yfinance-like interface for cryptocurrency data from BtcTurk.
 17
 18    Examples:
 19        >>> import borsapy as bp
 20        >>> btc = bp.Crypto("BTCTRY")
 21        >>> btc.current
 22        {'symbol': 'BTCTRY', 'last': 3500000.0, ...}
 23        >>> btc.history(period="1mo")
 24                             Open       High        Low      Close      Volume
 25        Date
 26        2024-12-01   3400000.0  3550000.0  3380000.0  3500000.0   1234.5678
 27        ...
 28
 29        >>> eth = bp.Crypto("ETHTRY")
 30        >>> eth.current['last']
 31        125000.0
 32    """
 33
 34    def __init__(self, pair: str):
 35        """
 36        Initialize a Crypto object.
 37
 38        Args:
 39            pair: Trading pair (e.g., "BTCTRY", "ETHTRY", "BTCUSDT").
 40                  Common pairs: BTCTRY, ETHTRY, XRPTRY, DOGETRY, SOLTRY
 41        """
 42        self._pair = pair.upper()
 43        self._provider = get_btcturk_provider()
 44        self._current_cache: dict[str, Any] | None = None
 45
 46    def _get_tweet_query(self) -> str:
 47        return _build_crypto_query(self._pair)
 48
 49    @property
 50    def pair(self) -> str:
 51        """Return the trading pair."""
 52        return self._pair
 53
 54    @property
 55    def symbol(self) -> str:
 56        """Return the trading pair (alias)."""
 57        return self._pair
 58
 59    @property
 60    def current(self) -> dict[str, Any]:
 61        """
 62        Get current ticker information.
 63
 64        Returns:
 65            Dictionary with current market data:
 66            - symbol: Trading pair
 67            - last: Last traded price
 68            - open: Opening price
 69            - high: 24h high
 70            - low: 24h low
 71            - bid: Best bid price
 72            - ask: Best ask price
 73            - volume: 24h volume
 74            - change: Price change
 75            - change_percent: Percent change
 76        """
 77        if self._current_cache is None:
 78            self._current_cache = self._provider.get_ticker(self._pair)
 79        return self._current_cache
 80
 81    @property
 82    def info(self) -> dict[str, Any]:
 83        """Alias for current property (yfinance compatibility)."""
 84        return self.current
 85
 86    def history(
 87        self,
 88        period: str = "1mo",
 89        interval: str = "1d",
 90        start: datetime | str | None = None,
 91        end: datetime | str | None = None,
 92    ) -> pd.DataFrame:
 93        """
 94        Get historical OHLCV data.
 95
 96        Args:
 97            period: How much data to fetch. Valid periods:
 98                    1d, 5d, 1mo, 3mo, 6mo, 1y.
 99                    Ignored if start is provided.
100            interval: Data granularity. Valid intervals:
101                      1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk.
102            start: Start date (string or datetime).
103            end: End date (string or datetime). Defaults to now.
104
105        Returns:
106            DataFrame with columns: Open, High, Low, Close, Volume.
107            Index is the Date.
108
109        Examples:
110            >>> crypto = Crypto("BTCTRY")
111            >>> crypto.history(period="1mo")  # Last month
112            >>> crypto.history(period="1y", interval="1wk")  # Weekly for 1 year
113            >>> crypto.history(start="2024-01-01", end="2024-06-30")  # Date range
114        """
115        start_dt = self._parse_date(start) if start else None
116        end_dt = self._parse_date(end) if end else None
117
118        return self._provider.get_history(
119            pair=self._pair,
120            period=period,
121            interval=interval,
122            start=start_dt,
123            end=end_dt,
124        )
125
126    def _parse_date(self, date: str | datetime) -> datetime:
127        """Parse a date string to datetime."""
128        if isinstance(date, datetime):
129            return date
130        for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"]:
131            try:
132                return datetime.strptime(date, fmt)
133            except ValueError:
134                continue
135        raise ValueError(f"Could not parse date: {date}")
136
137    def _get_ta_symbol_info(self) -> tuple[str, str]:
138        """Get TradingView symbol and screener for TA signals.
139
140        Maps BtcTurk pairs to Binance USDT pairs for better TradingView coverage.
141
142        Returns:
143            Tuple of (tv_symbol, screener) for TradingView Scanner API.
144        """
145        # Extract base currency from pair (e.g., "BTCTRY" -> "BTC")
146        base = self._pair.replace("TRY", "").replace("USDT", "")
147        # Use Binance USDT pair for better TradingView coverage
148        return (f"BINANCE:{base}USDT", "crypto")
149
150    def __repr__(self) -> str:
151        return f"Crypto('{self._pair}')"

A yfinance-like interface for cryptocurrency data from BtcTurk.

Examples:

import borsapy as bp btc = bp.Crypto("BTCTRY") btc.current {'symbol': 'BTCTRY', 'last': 3500000.0, ...} btc.history(period="1mo") Open High Low Close Volume Date 2024-12-01 3400000.0 3550000.0 3380000.0 3500000.0 1234.5678 ...

>>> eth = bp.Crypto("ETHTRY")
>>> eth.current['last']
125000.0
Crypto(pair: str)
34    def __init__(self, pair: str):
35        """
36        Initialize a Crypto object.
37
38        Args:
39            pair: Trading pair (e.g., "BTCTRY", "ETHTRY", "BTCUSDT").
40                  Common pairs: BTCTRY, ETHTRY, XRPTRY, DOGETRY, SOLTRY
41        """
42        self._pair = pair.upper()
43        self._provider = get_btcturk_provider()
44        self._current_cache: dict[str, Any] | None = None

Initialize a Crypto object.

Args: pair: Trading pair (e.g., "BTCTRY", "ETHTRY", "BTCUSDT"). Common pairs: BTCTRY, ETHTRY, XRPTRY, DOGETRY, SOLTRY

pair: str
49    @property
50    def pair(self) -> str:
51        """Return the trading pair."""
52        return self._pair

Return the trading pair.

symbol: str
54    @property
55    def symbol(self) -> str:
56        """Return the trading pair (alias)."""
57        return self._pair

Return the trading pair (alias).

current: dict[str, typing.Any]
59    @property
60    def current(self) -> dict[str, Any]:
61        """
62        Get current ticker information.
63
64        Returns:
65            Dictionary with current market data:
66            - symbol: Trading pair
67            - last: Last traded price
68            - open: Opening price
69            - high: 24h high
70            - low: 24h low
71            - bid: Best bid price
72            - ask: Best ask price
73            - volume: 24h volume
74            - change: Price change
75            - change_percent: Percent change
76        """
77        if self._current_cache is None:
78            self._current_cache = self._provider.get_ticker(self._pair)
79        return self._current_cache

Get current ticker information.

Returns: Dictionary with current market data: - symbol: Trading pair - last: Last traded price - open: Opening price - high: 24h high - low: 24h low - bid: Best bid price - ask: Best ask price - volume: 24h volume - change: Price change - change_percent: Percent change

info: dict[str, typing.Any]
81    @property
82    def info(self) -> dict[str, Any]:
83        """Alias for current property (yfinance compatibility)."""
84        return self.current

Alias for current property (yfinance compatibility).

def history( self, period: str = '1mo', interval: str = '1d', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None) -> pandas.core.frame.DataFrame:
 86    def history(
 87        self,
 88        period: str = "1mo",
 89        interval: str = "1d",
 90        start: datetime | str | None = None,
 91        end: datetime | str | None = None,
 92    ) -> pd.DataFrame:
 93        """
 94        Get historical OHLCV data.
 95
 96        Args:
 97            period: How much data to fetch. Valid periods:
 98                    1d, 5d, 1mo, 3mo, 6mo, 1y.
 99                    Ignored if start is provided.
100            interval: Data granularity. Valid intervals:
101                      1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk.
102            start: Start date (string or datetime).
103            end: End date (string or datetime). Defaults to now.
104
105        Returns:
106            DataFrame with columns: Open, High, Low, Close, Volume.
107            Index is the Date.
108
109        Examples:
110            >>> crypto = Crypto("BTCTRY")
111            >>> crypto.history(period="1mo")  # Last month
112            >>> crypto.history(period="1y", interval="1wk")  # Weekly for 1 year
113            >>> crypto.history(start="2024-01-01", end="2024-06-30")  # Date range
114        """
115        start_dt = self._parse_date(start) if start else None
116        end_dt = self._parse_date(end) if end else None
117
118        return self._provider.get_history(
119            pair=self._pair,
120            period=period,
121            interval=interval,
122            start=start_dt,
123            end=end_dt,
124        )

Get historical OHLCV data.

Args: period: How much data to fetch. Valid periods: 1d, 5d, 1mo, 3mo, 6mo, 1y. Ignored if start is provided. interval: Data granularity. Valid intervals: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk. start: Start date (string or datetime). end: End date (string or datetime). Defaults to now.

Returns: DataFrame with columns: Open, High, Low, Close, Volume. Index is the Date.

Examples:

crypto = Crypto("BTCTRY") crypto.history(period="1mo") # Last month crypto.history(period="1y", interval="1wk") # Weekly for 1 year crypto.history(start="2024-01-01", end="2024-06-30") # Date range

class Fund(borsapy.technical.TechnicalMixin, borsapy.twitter.TwitterMixin):
 16class Fund(TechnicalMixin, TwitterMixin):
 17    """
 18    A yfinance-like interface for mutual fund data from TEFAS.
 19
 20    Examples:
 21        >>> import borsapy as bp
 22        >>> fund = bp.Fund("AAK")
 23        >>> fund.info
 24        {'fund_code': 'AAK', 'name': 'Ak Portföy...', 'price': 1.234, ...}
 25        >>> fund.history(period="1mo")
 26                         Price      FundSize  Investors
 27        Date
 28        2024-12-01      1.200  150000000.0       5000
 29        ...
 30
 31        >>> fund = bp.Fund("TTE")
 32        >>> fund.info['return_1y']
 33        45.67
 34    """
 35
 36    def __init__(self, fund_code: str, fund_type: str | None = None):
 37        """
 38        Initialize a Fund object.
 39
 40        Args:
 41            fund_code: TEFAS fund code (e.g., "AAK", "TTE", "YAF")
 42            fund_type: Fund type - "YAT" for investment funds, "EMK" for pension funds.
 43                      If None, auto-detects by trying YAT first, then EMK.
 44
 45        Examples:
 46            >>> fund = bp.Fund("AAK")              # Investment fund (auto-detect)
 47            >>> fund = bp.Fund("HEF", fund_type="EMK")  # Pension fund (explicit)
 48        """
 49        self._fund_code = fund_code.upper()
 50        self._fund_type = fund_type.upper() if fund_type else None
 51        self._provider = get_tefas_provider()
 52        self._info_cache: dict[str, Any] | None = None
 53        self._detected_fund_type: str | None = None
 54
 55    def _get_tweet_query(self) -> str:
 56        name = None
 57        try:
 58            name = self.info.get("name")
 59        except Exception:
 60            pass
 61        return _build_fund_query(self._fund_code, name)
 62
 63    @property
 64    def fund_code(self) -> str:
 65        """Return the fund code."""
 66        return self._fund_code
 67
 68    @property
 69    def symbol(self) -> str:
 70        """Return the fund code (alias)."""
 71        return self._fund_code
 72
 73    @property
 74    def fund_type(self) -> str:
 75        """
 76        Return the fund type ("YAT" or "EMK").
 77
 78        If not explicitly set, auto-detects on first history() or allocation() call.
 79        """
 80        if self._fund_type:
 81            return self._fund_type
 82        if self._detected_fund_type:
 83            return self._detected_fund_type
 84
 85        # Auto-detect by trying history with YAT first, then EMK
 86        self._detect_fund_type()
 87        return self._detected_fund_type or "YAT"
 88
 89    def _detect_fund_type(self) -> None:
 90        """Auto-detect fund_class ("YAT" vs "EMK") via :meth:`info`.
 91
 92        :class:`TEFASProvider.get_fund_detail` populates ``fund_class`` by
 93        looking the fund up in the YAT and EMK returns lists, so we just
 94        read it back here. Defaults to "YAT" when detection fails.
 95        """
 96        if self._fund_type or self._detected_fund_type:
 97            return
 98
 99        try:
100            detected = self.info.get("fund_class")
101        except (DataNotAvailableError, Exception):  # noqa: BLE001
102            detected = None
103
104        self._detected_fund_type = detected or "YAT"
105
106    @property
107    def info(self) -> dict[str, Any]:
108        """
109        Get detailed fund information.
110
111        Returns:
112            Dictionary with fund details:
113            - fund_code: TEFAS fund code
114            - name: Fund full name
115            - date: Last update date
116            - price: Current unit price
117            - fund_size: Total fund size (TRY)
118            - investor_count: Number of investors
119            - founder: Fund founder company
120            - manager: Fund manager company
121            - fund_type: Fund type
122            - category: Fund category
123            - risk_value: Risk rating (1-7)
124            - return_1m, return_3m, return_6m: Period returns
125            - return_ytd: Year-to-date return
126            - return_1y, return_3y, return_5y: Annual returns
127            - daily_return: Daily return
128        """
129        if self._info_cache is None:
130            # fonBilgiGetir works for both YAT and EMK without fontip
131            self._info_cache = self._provider.get_fund_detail(self._fund_code)
132
133            # If fund_type not explicitly set, we need to detect it for history/allocation
134            if not self._fund_type and not self._detected_fund_type:
135                # Detection will happen on first history() call
136                pass
137
138        return self._info_cache
139
140    @property
141    def detail(self) -> dict[str, Any]:
142        """Alias for info property."""
143        return self.info
144
145    @property
146    def performance(self) -> dict[str, Any]:
147        """
148        Get fund performance metrics only.
149
150        Returns:
151            Dictionary with performance data:
152            - daily_return: Daily return
153            - return_1m, return_3m, return_6m: Period returns
154            - return_ytd: Year-to-date return
155            - return_1y, return_3y, return_5y: Annual returns
156        """
157        info = self.info
158        return {
159            "daily_return": info.get("daily_return"),
160            "return_1m": info.get("return_1m"),
161            "return_3m": info.get("return_3m"),
162            "return_6m": info.get("return_6m"),
163            "return_ytd": info.get("return_ytd"),
164            "return_1y": info.get("return_1y"),
165            "return_3y": info.get("return_3y"),
166            "return_5y": info.get("return_5y"),
167        }
168
169    @property
170    def management_fee(self) -> dict[str, Any]:
171        """
172        Get management fee information for this fund.
173
174        Returns:
175            Dictionary with keys:
176            - applied_fee: Applied annual management fee (%)
177            - prospectus_fee: Prospectus management fee (%)
178            - max_expense_ratio: Maximum total expense ratio (%)
179            - annual_return: Annual return (%)
180
181        Examples:
182            >>> fund = bp.Fund("AAK")
183            >>> fund.management_fee
184            {'applied_fee': 1.0, 'prospectus_fee': 2.2, 'max_expense_ratio': 3.65, 'annual_return': 45.5}
185        """
186        empty = {
187            "applied_fee": None,
188            "prospectus_fee": None,
189            "max_expense_ratio": None,
190            "annual_return": None,
191        }
192
193        try:
194            fees_list = self._provider.get_management_fees(fund_type=self.fund_type)
195        except Exception:
196            return empty
197
198        for item in fees_list:
199            if item.get("fund_code") == self._fund_code:
200                return {
201                    "applied_fee": item.get("applied_fee"),
202                    "prospectus_fee": item.get("prospectus_fee"),
203                    "max_expense_ratio": item.get("max_expense_ratio"),
204                    "annual_return": item.get("annual_return"),
205                }
206
207        return empty
208
209    @property
210    def tax_category(self) -> str | None:
211        """
212        Get the tax category for this fund based on its TEFAS category.
213
214        Returns:
215            Tax category identifier string (e.g., "degisken_karma_doviz",
216            "pay_senedi_yogun"), or None if the category cannot be determined.
217
218        Examples:
219            >>> fund = bp.Fund("AAK")
220            >>> fund.tax_category
221            'borclanma_para_maden'
222        """
223        from borsapy.tax import classify_fund_tax_category
224
225        info = self.info
226        category = info.get("category", "") or ""
227        fund_name = info.get("name", "") or ""
228        return classify_fund_tax_category(category, fund_name)
229
230    def withholding_tax_rate(
231        self,
232        purchase_date: datetime | str | None = None,
233        holding_days: int | None = None,
234    ) -> float | None:
235        """
236        Get the withholding tax (stopaj) rate for this fund.
237
238        Args:
239            purchase_date: Date of fund purchase. Accepts datetime, date, or
240                          "YYYY-MM-DD" string. Defaults to today.
241            holding_days: Number of days held. Relevant for GSYF/GYF funds
242                         where >730 days qualifies for 0% rate.
243
244        Returns:
245            Tax rate as a decimal (e.g., 0.15 for 15%), or None if the
246            fund category cannot be determined.
247
248        Examples:
249            >>> fund = bp.Fund("AAK")
250            >>> fund.withholding_tax_rate("2025-06-01")
251            0.15
252            >>> fund.withholding_tax_rate("2025-08-01")
253            0.175
254        """
255        from datetime import date
256
257        from borsapy.tax import get_withholding_tax_rate
258
259        cat = self.tax_category
260        if cat is None:
261            return None
262        if purchase_date is None:
263            purchase_date = date.today()
264        elif isinstance(purchase_date, datetime):
265            purchase_date = purchase_date.date()
266        return get_withholding_tax_rate(cat, purchase_date, holding_days)
267
268    @property
269    def allocation(self) -> pd.DataFrame:
270        """Get the current portfolio allocation (asset breakdown).
271
272        After the 2026-04 TEFAS migration, allocation data is only available
273        through the Akamai-protected SSR HTML page. This property requires
274        Scrapling (patchright-based stealth Chromium)::
275
276            pip install borsapy[allocation]
277            playwright install chromium  # one-time browser binary download
278
279        Only the current snapshot is returned (one row per asset class).
280        Historical allocation is no longer available via TEFAS.
281
282        Returns:
283            DataFrame with columns ``Date``, ``asset_type``, ``asset_name``,
284            ``weight``.
285
286        Examples:
287            >>> fund = Fund("AAK")
288            >>> fund.allocation
289                 Date              asset_type     asset_name   weight
290            0    2026-05-02        Hisse Senedi  Stocks         29.75
291            1    2026-05-02        Ters-Repo     Reverse Repo   18.40
292            ...
293        """
294        return self._provider.get_allocation(self._fund_code, fund_type=self.fund_type)
295
296    def allocation_history(
297        self,
298        period: str = "1mo",
299        start: datetime | str | None = None,
300        end: datetime | str | None = None,
301    ) -> pd.DataFrame:
302        """Get the current portfolio allocation snapshot.
303
304        .. deprecated:: 0.9.0
305            Historical allocation is no longer available from TEFAS — the
306            new Next.js architecture only renders the *current* allocation
307            snapshot in the SSR HTML. This method returns the same data as
308            :attr:`allocation` regardless of ``period``/``start``/``end``.
309            A ``DeprecationWarning`` is emitted on each call.
310
311        Args:
312            period: Ignored (kept for backward compatibility).
313            start: Ignored (kept for backward compatibility).
314            end: Ignored (kept for backward compatibility).
315
316        Returns:
317            DataFrame — same shape as :attr:`allocation`.
318        """
319        import warnings
320
321        warnings.warn(
322            "Fund.allocation_history() is deprecated since v0.9.0: TEFAS no "
323            "longer exposes historical allocation. Returning the current "
324            "snapshot (same as Fund.allocation).",
325            DeprecationWarning,
326            stacklevel=2,
327        )
328        # period/start/end are intentionally unused; only the snapshot is
329        # available now.
330        del period, start, end
331        return self.allocation
332
333    def history(
334        self,
335        period: str = "1mo",
336        start: datetime | str | None = None,
337        end: datetime | str | None = None,
338    ) -> pd.DataFrame:
339        """Get historical NAV (unit price) for the fund.
340
341        Backed by the new ``fonFiyatBilgiGetir`` endpoint (TEFAS v2 API,
342        2026-04). Maximum window is **5 years** — ``period="max"`` is capped
343        at 5y. Arbitrary ``start``/``end`` ranges are supported by fetching
344        the smallest covering bucket and filtering client-side.
345
346        Args:
347            period: One of ``1d``, ``5d``, ``1mo``, ``3mo``, ``6mo``, ``ytd``,
348                ``1y``, ``3y``, ``5y``, ``max``. Ignored when ``start`` is
349                given.
350            start: Start date (string or datetime).
351            end: End date (string or datetime). Defaults to now.
352
353        Returns:
354            DataFrame indexed by ``Date`` with column ``Price``. The
355            ``FundSize`` and ``Investors`` columns are kept for backward
356            compatibility but are now NaN/0 — the new API no longer returns
357            these.
358
359        Examples:
360            >>> fund = Fund("AAK")
361            >>> fund.history(period="1mo")
362            >>> fund.history(period="5y")  # max supported
363            >>> fund.history(start="2024-01-01", end="2024-06-30")
364        """
365        start_dt = self._parse_date(start) if start else None
366        end_dt = self._parse_date(end) if end else None
367
368        return self._provider.get_history(
369            fund_code=self._fund_code,
370            period=period,
371            start=start_dt,
372            end=end_dt,
373            fund_type=self.fund_type,
374        )
375
376    def _parse_date(self, date: str | datetime) -> datetime:
377        """Parse a date string to datetime."""
378        if isinstance(date, datetime):
379            return date
380        for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"]:
381            try:
382                return datetime.strptime(date, fmt)
383            except ValueError:
384                continue
385        raise ValueError(f"Could not parse date: {date}")
386
387    def sharpe_ratio(self, period: str = "1y", risk_free_rate: float | None = None) -> float:
388        """
389        Calculate the Sharpe ratio for the fund.
390
391        Sharpe Ratio = (Rp - Rf) / σp
392        Where:
393        - Rp = Annualized return of the fund
394        - Rf = Risk-free rate (default: 10Y government bond yield)
395        - σp = Annualized standard deviation of returns
396
397        Args:
398            period: Period for calculation ("1y", "3y", "5y"). Default is "1y".
399            risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
400                           If None, uses current 10Y bond yield from bp.risk_free_rate().
401
402        Returns:
403            Sharpe ratio as float. Higher is better (>1 good, >2 very good, >3 excellent).
404
405        Examples:
406            >>> fund = bp.Fund("YAY")
407            >>> fund.sharpe_ratio()  # 1-year Sharpe with current risk-free rate
408            0.85
409
410            >>> fund.sharpe_ratio(period="3y")  # 3-year Sharpe
411            1.23
412
413            >>> fund.sharpe_ratio(risk_free_rate=0.25)  # Custom risk-free rate
414            0.92
415        """
416        metrics = self.risk_metrics(period=period, risk_free_rate=risk_free_rate)
417        return metrics.get("sharpe_ratio", np.nan)
418
419    def risk_metrics(
420        self,
421        period: str = "1y",
422        risk_free_rate: float | None = None,
423    ) -> dict[str, Any]:
424        """
425        Calculate comprehensive risk metrics for the fund.
426
427        Args:
428            period: Period for calculation ("1y", "3y", "5y"). Default is "1y".
429            risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
430                           If None, uses current 10Y bond yield.
431
432        Returns:
433            Dictionary with risk metrics:
434            - annualized_return: Annualized return (%)
435            - annualized_volatility: Annualized standard deviation (%)
436            - sharpe_ratio: Risk-adjusted return (Rp - Rf) / σp
437            - sortino_ratio: Downside risk-adjusted return
438            - max_drawdown: Maximum peak-to-trough decline (%)
439            - risk_free_rate: Risk-free rate used (%)
440            - trading_days: Number of trading days in the period
441
442        Examples:
443            >>> fund = bp.Fund("YAY")
444            >>> metrics = fund.risk_metrics()
445            >>> print(f"Sharpe: {metrics['sharpe_ratio']:.2f}")
446            >>> print(f"Max Drawdown: {metrics['max_drawdown']:.1f}%")
447        """
448        # Get historical data
449        df = self.history(period=period)
450
451        if df.empty or len(df) < 20:
452            return {
453                "annualized_return": np.nan,
454                "annualized_volatility": np.nan,
455                "sharpe_ratio": np.nan,
456                "sortino_ratio": np.nan,
457                "max_drawdown": np.nan,
458                "risk_free_rate": np.nan,
459                "trading_days": 0,
460            }
461
462        # Calculate daily returns
463        prices = df["Price"]
464        daily_returns = prices.pct_change().dropna()
465        trading_days = len(daily_returns)
466
467        # Annualization factor (trading days per year)
468        annualization_factor = 252
469
470        # Annualized return
471        total_return = (prices.iloc[-1] / prices.iloc[0]) - 1
472        years = trading_days / annualization_factor
473        annualized_return = ((1 + total_return) ** (1 / years) - 1) * 100
474
475        # Annualized volatility
476        daily_volatility = daily_returns.std()
477        annualized_volatility = daily_volatility * np.sqrt(annualization_factor) * 100
478
479        # Get risk-free rate
480        if risk_free_rate is None:
481            try:
482                from borsapy.bond import risk_free_rate as get_rf_rate
483                rf = get_rf_rate() * 100  # Returns decimal like 0.28, convert to %
484            except Exception:
485                rf = 30.0  # Fallback: approximate Turkish 10Y yield
486        else:
487            rf = risk_free_rate * 100  # Convert decimal to percentage
488
489        # Sharpe Ratio
490        if annualized_volatility > 0:
491            sharpe = (annualized_return - rf) / annualized_volatility
492        else:
493            sharpe = np.nan
494
495        # Sortino Ratio (uses downside deviation)
496        negative_returns = daily_returns[daily_returns < 0]
497        if len(negative_returns) > 0:
498            downside_deviation = negative_returns.std() * np.sqrt(annualization_factor) * 100
499            if downside_deviation > 0:
500                sortino = (annualized_return - rf) / downside_deviation
501            else:
502                sortino = np.nan
503        else:
504            sortino = np.inf  # No negative returns
505
506        # Maximum Drawdown
507        cumulative = (1 + daily_returns).cumprod()
508        running_max = cumulative.cummax()
509        drawdowns = (cumulative - running_max) / running_max
510        max_drawdown = drawdowns.min() * 100  # Negative percentage
511
512        return {
513            "annualized_return": round(annualized_return, 2),
514            "annualized_volatility": round(annualized_volatility, 2),
515            "sharpe_ratio": round(sharpe, 2) if not np.isnan(sharpe) else np.nan,
516            "sortino_ratio": round(sortino, 2) if not np.isnan(sortino) and not np.isinf(sortino) else sortino,
517            "max_drawdown": round(max_drawdown, 2),
518            "risk_free_rate": round(rf, 2),
519            "trading_days": trading_days,
520        }
521
522    def get_holdings(
523        self,
524        api_key: str,
525        period: str | None = None,
526    ) -> pd.DataFrame:
527        """
528        Get detailed portfolio holdings (individual securities).
529
530        Returns the specific stocks, ETFs, and funds held by this fund,
531        with their weights and ISIN codes. Data is sourced from KAP
532        "Portföy Dağılım Raporu" (Portfolio Distribution Report) disclosures.
533
534        Uses OpenRouter LLM for PDF parsing.
535
536        Args:
537            api_key: OpenRouter API key for LLM parsing.
538                    Get your free API key at: https://openrouter.ai/
539            period: Optional period in format "YYYY-MM" (e.g., "2025-12").
540                   If None, returns the most recent holdings.
541
542        Returns:
543            DataFrame with columns:
544            - symbol: Security symbol (e.g., "GOOGL", "THYAO")
545            - isin: ISIN code
546            - name: Full security name
547            - weight: Portfolio weight (%)
548            - type: Holding type ('stock', 'etf', 'fund', 'viop', etc.)
549            - country: Country ('TR', 'US', or None)
550            - value: Market value in TRY
551
552        Raises:
553            DataNotAvailableError: If holdings data not available.
554            APIError: If LLM parsing fails.
555            ImportError: If required packages are not installed.
556
557        Examples:
558            >>> fund = bp.Fund("YAY")
559            >>> fund.get_holdings(api_key="sk-or-v1-...")
560               symbol              isin                              name  weight   type country         value
561            0   GOOGL  US02079K3059             ALPHABET INC CL A    6.76  stock      US  82478088.0
562            1    AVGO  US11135F1012             BROADCOM INC          5.11  stock      US  62345678.0
563            ...
564
565            >>> # Get holdings for specific period
566            >>> fund.get_holdings(api_key="sk-or-v1-...", period="2025-12")
567
568            >>> # Filter by type
569            >>> holdings = fund.get_holdings(api_key="sk-or-v1-...")
570            >>> holdings[holdings['type'] == 'stock']
571        """
572        from borsapy._providers.kap_holdings import get_kap_holdings_provider
573
574        provider = get_kap_holdings_provider()
575        return provider.get_holdings_df(self._fund_code, api_key, period=period)
576
577    def __repr__(self) -> str:
578        return f"Fund('{self._fund_code}')"

A yfinance-like interface for mutual fund data from TEFAS.

Examples:

import borsapy as bp fund = bp.Fund("AAK") fund.info {'fund_code': 'AAK', 'name': 'Ak Portföy...', 'price': 1.234, ...} fund.history(period="1mo") Price FundSize Investors Date 2024-12-01 1.200 150000000.0 5000 ...

>>> fund = bp.Fund("TTE")
>>> fund.info['return_1y']
45.67
Fund(fund_code: str, fund_type: str | None = None)
36    def __init__(self, fund_code: str, fund_type: str | None = None):
37        """
38        Initialize a Fund object.
39
40        Args:
41            fund_code: TEFAS fund code (e.g., "AAK", "TTE", "YAF")
42            fund_type: Fund type - "YAT" for investment funds, "EMK" for pension funds.
43                      If None, auto-detects by trying YAT first, then EMK.
44
45        Examples:
46            >>> fund = bp.Fund("AAK")              # Investment fund (auto-detect)
47            >>> fund = bp.Fund("HEF", fund_type="EMK")  # Pension fund (explicit)
48        """
49        self._fund_code = fund_code.upper()
50        self._fund_type = fund_type.upper() if fund_type else None
51        self._provider = get_tefas_provider()
52        self._info_cache: dict[str, Any] | None = None
53        self._detected_fund_type: str | None = None

Initialize a Fund object.

Args: fund_code: TEFAS fund code (e.g., "AAK", "TTE", "YAF") fund_type: Fund type - "YAT" for investment funds, "EMK" for pension funds. If None, auto-detects by trying YAT first, then EMK.

Examples:

fund = bp.Fund("AAK") # Investment fund (auto-detect) fund = bp.Fund("HEF", fund_type="EMK") # Pension fund (explicit)

fund_code: str
63    @property
64    def fund_code(self) -> str:
65        """Return the fund code."""
66        return self._fund_code

Return the fund code.

symbol: str
68    @property
69    def symbol(self) -> str:
70        """Return the fund code (alias)."""
71        return self._fund_code

Return the fund code (alias).

fund_type: str
73    @property
74    def fund_type(self) -> str:
75        """
76        Return the fund type ("YAT" or "EMK").
77
78        If not explicitly set, auto-detects on first history() or allocation() call.
79        """
80        if self._fund_type:
81            return self._fund_type
82        if self._detected_fund_type:
83            return self._detected_fund_type
84
85        # Auto-detect by trying history with YAT first, then EMK
86        self._detect_fund_type()
87        return self._detected_fund_type or "YAT"

Return the fund type ("YAT" or "EMK").

If not explicitly set, auto-detects on first history() or allocation() call.

info: dict[str, typing.Any]
106    @property
107    def info(self) -> dict[str, Any]:
108        """
109        Get detailed fund information.
110
111        Returns:
112            Dictionary with fund details:
113            - fund_code: TEFAS fund code
114            - name: Fund full name
115            - date: Last update date
116            - price: Current unit price
117            - fund_size: Total fund size (TRY)
118            - investor_count: Number of investors
119            - founder: Fund founder company
120            - manager: Fund manager company
121            - fund_type: Fund type
122            - category: Fund category
123            - risk_value: Risk rating (1-7)
124            - return_1m, return_3m, return_6m: Period returns
125            - return_ytd: Year-to-date return
126            - return_1y, return_3y, return_5y: Annual returns
127            - daily_return: Daily return
128        """
129        if self._info_cache is None:
130            # fonBilgiGetir works for both YAT and EMK without fontip
131            self._info_cache = self._provider.get_fund_detail(self._fund_code)
132
133            # If fund_type not explicitly set, we need to detect it for history/allocation
134            if not self._fund_type and not self._detected_fund_type:
135                # Detection will happen on first history() call
136                pass
137
138        return self._info_cache

Get detailed fund information.

Returns: Dictionary with fund details: - fund_code: TEFAS fund code - name: Fund full name - date: Last update date - price: Current unit price - fund_size: Total fund size (TRY) - investor_count: Number of investors - founder: Fund founder company - manager: Fund manager company - fund_type: Fund type - category: Fund category - risk_value: Risk rating (1-7) - return_1m, return_3m, return_6m: Period returns - return_ytd: Year-to-date return - return_1y, return_3y, return_5y: Annual returns - daily_return: Daily return

detail: dict[str, typing.Any]
140    @property
141    def detail(self) -> dict[str, Any]:
142        """Alias for info property."""
143        return self.info

Alias for info property.

performance: dict[str, typing.Any]
145    @property
146    def performance(self) -> dict[str, Any]:
147        """
148        Get fund performance metrics only.
149
150        Returns:
151            Dictionary with performance data:
152            - daily_return: Daily return
153            - return_1m, return_3m, return_6m: Period returns
154            - return_ytd: Year-to-date return
155            - return_1y, return_3y, return_5y: Annual returns
156        """
157        info = self.info
158        return {
159            "daily_return": info.get("daily_return"),
160            "return_1m": info.get("return_1m"),
161            "return_3m": info.get("return_3m"),
162            "return_6m": info.get("return_6m"),
163            "return_ytd": info.get("return_ytd"),
164            "return_1y": info.get("return_1y"),
165            "return_3y": info.get("return_3y"),
166            "return_5y": info.get("return_5y"),
167        }

Get fund performance metrics only.

Returns: Dictionary with performance data: - daily_return: Daily return - return_1m, return_3m, return_6m: Period returns - return_ytd: Year-to-date return - return_1y, return_3y, return_5y: Annual returns

management_fee: dict[str, typing.Any]
169    @property
170    def management_fee(self) -> dict[str, Any]:
171        """
172        Get management fee information for this fund.
173
174        Returns:
175            Dictionary with keys:
176            - applied_fee: Applied annual management fee (%)
177            - prospectus_fee: Prospectus management fee (%)
178            - max_expense_ratio: Maximum total expense ratio (%)
179            - annual_return: Annual return (%)
180
181        Examples:
182            >>> fund = bp.Fund("AAK")
183            >>> fund.management_fee
184            {'applied_fee': 1.0, 'prospectus_fee': 2.2, 'max_expense_ratio': 3.65, 'annual_return': 45.5}
185        """
186        empty = {
187            "applied_fee": None,
188            "prospectus_fee": None,
189            "max_expense_ratio": None,
190            "annual_return": None,
191        }
192
193        try:
194            fees_list = self._provider.get_management_fees(fund_type=self.fund_type)
195        except Exception:
196            return empty
197
198        for item in fees_list:
199            if item.get("fund_code") == self._fund_code:
200                return {
201                    "applied_fee": item.get("applied_fee"),
202                    "prospectus_fee": item.get("prospectus_fee"),
203                    "max_expense_ratio": item.get("max_expense_ratio"),
204                    "annual_return": item.get("annual_return"),
205                }
206
207        return empty

Get management fee information for this fund.

Returns: Dictionary with keys: - applied_fee: Applied annual management fee (%) - prospectus_fee: Prospectus management fee (%) - max_expense_ratio: Maximum total expense ratio (%) - annual_return: Annual return (%)

Examples:

fund = bp.Fund("AAK") fund.management_fee {'applied_fee': 1.0, 'prospectus_fee': 2.2, 'max_expense_ratio': 3.65, 'annual_return': 45.5}

tax_category: str | None
209    @property
210    def tax_category(self) -> str | None:
211        """
212        Get the tax category for this fund based on its TEFAS category.
213
214        Returns:
215            Tax category identifier string (e.g., "degisken_karma_doviz",
216            "pay_senedi_yogun"), or None if the category cannot be determined.
217
218        Examples:
219            >>> fund = bp.Fund("AAK")
220            >>> fund.tax_category
221            'borclanma_para_maden'
222        """
223        from borsapy.tax import classify_fund_tax_category
224
225        info = self.info
226        category = info.get("category", "") or ""
227        fund_name = info.get("name", "") or ""
228        return classify_fund_tax_category(category, fund_name)

Get the tax category for this fund based on its TEFAS category.

Returns: Tax category identifier string (e.g., "degisken_karma_doviz", "pay_senedi_yogun"), or None if the category cannot be determined.

Examples:

fund = bp.Fund("AAK") fund.tax_category 'borclanma_para_maden'

def withholding_tax_rate( self, purchase_date: datetime.datetime | str | None = None, holding_days: int | None = None) -> float | None:
230    def withholding_tax_rate(
231        self,
232        purchase_date: datetime | str | None = None,
233        holding_days: int | None = None,
234    ) -> float | None:
235        """
236        Get the withholding tax (stopaj) rate for this fund.
237
238        Args:
239            purchase_date: Date of fund purchase. Accepts datetime, date, or
240                          "YYYY-MM-DD" string. Defaults to today.
241            holding_days: Number of days held. Relevant for GSYF/GYF funds
242                         where >730 days qualifies for 0% rate.
243
244        Returns:
245            Tax rate as a decimal (e.g., 0.15 for 15%), or None if the
246            fund category cannot be determined.
247
248        Examples:
249            >>> fund = bp.Fund("AAK")
250            >>> fund.withholding_tax_rate("2025-06-01")
251            0.15
252            >>> fund.withholding_tax_rate("2025-08-01")
253            0.175
254        """
255        from datetime import date
256
257        from borsapy.tax import get_withholding_tax_rate
258
259        cat = self.tax_category
260        if cat is None:
261            return None
262        if purchase_date is None:
263            purchase_date = date.today()
264        elif isinstance(purchase_date, datetime):
265            purchase_date = purchase_date.date()
266        return get_withholding_tax_rate(cat, purchase_date, holding_days)

Get the withholding tax (stopaj) rate for this fund.

Args: purchase_date: Date of fund purchase. Accepts datetime, date, or "YYYY-MM-DD" string. Defaults to today. holding_days: Number of days held. Relevant for GSYF/GYF funds where >730 days qualifies for 0% rate.

Returns: Tax rate as a decimal (e.g., 0.15 for 15%), or None if the fund category cannot be determined.

Examples:

fund = bp.Fund("AAK") fund.withholding_tax_rate("2025-06-01") 0.15 fund.withholding_tax_rate("2025-08-01") 0.175

allocation: pandas.core.frame.DataFrame
268    @property
269    def allocation(self) -> pd.DataFrame:
270        """Get the current portfolio allocation (asset breakdown).
271
272        After the 2026-04 TEFAS migration, allocation data is only available
273        through the Akamai-protected SSR HTML page. This property requires
274        Scrapling (patchright-based stealth Chromium)::
275
276            pip install borsapy[allocation]
277            playwright install chromium  # one-time browser binary download
278
279        Only the current snapshot is returned (one row per asset class).
280        Historical allocation is no longer available via TEFAS.
281
282        Returns:
283            DataFrame with columns ``Date``, ``asset_type``, ``asset_name``,
284            ``weight``.
285
286        Examples:
287            >>> fund = Fund("AAK")
288            >>> fund.allocation
289                 Date              asset_type     asset_name   weight
290            0    2026-05-02        Hisse Senedi  Stocks         29.75
291            1    2026-05-02        Ters-Repo     Reverse Repo   18.40
292            ...
293        """
294        return self._provider.get_allocation(self._fund_code, fund_type=self.fund_type)

Get the current portfolio allocation (asset breakdown).

After the 2026-04 TEFAS migration, allocation data is only available through the Akamai-protected SSR HTML page. This property requires Scrapling (patchright-based stealth Chromium)::

pip install borsapy[allocation]
playwright install chromium  # one-time browser binary download

Only the current snapshot is returned (one row per asset class). Historical allocation is no longer available via TEFAS.

Returns: DataFrame with columns Date, asset_type, asset_name, weight.

Examples:

fund = Fund("AAK") fund.allocation Date asset_type asset_name weight 0 2026-05-02 Hisse Senedi Stocks 29.75 1 2026-05-02 Ters-Repo Reverse Repo 18.40 ...

def allocation_history( self, period: str = '1mo', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None) -> pandas.core.frame.DataFrame:
296    def allocation_history(
297        self,
298        period: str = "1mo",
299        start: datetime | str | None = None,
300        end: datetime | str | None = None,
301    ) -> pd.DataFrame:
302        """Get the current portfolio allocation snapshot.
303
304        .. deprecated:: 0.9.0
305            Historical allocation is no longer available from TEFAS — the
306            new Next.js architecture only renders the *current* allocation
307            snapshot in the SSR HTML. This method returns the same data as
308            :attr:`allocation` regardless of ``period``/``start``/``end``.
309            A ``DeprecationWarning`` is emitted on each call.
310
311        Args:
312            period: Ignored (kept for backward compatibility).
313            start: Ignored (kept for backward compatibility).
314            end: Ignored (kept for backward compatibility).
315
316        Returns:
317            DataFrame — same shape as :attr:`allocation`.
318        """
319        import warnings
320
321        warnings.warn(
322            "Fund.allocation_history() is deprecated since v0.9.0: TEFAS no "
323            "longer exposes historical allocation. Returning the current "
324            "snapshot (same as Fund.allocation).",
325            DeprecationWarning,
326            stacklevel=2,
327        )
328        # period/start/end are intentionally unused; only the snapshot is
329        # available now.
330        del period, start, end
331        return self.allocation

Get the current portfolio allocation snapshot.

Deprecated since version 0.9.0: Historical allocation is no longer available from TEFAS — the new Next.js architecture only renders the current allocation snapshot in the SSR HTML. This method returns the same data as allocation regardless of period/start/end. A DeprecationWarning is emitted on each call.

Args: period: Ignored (kept for backward compatibility). start: Ignored (kept for backward compatibility). end: Ignored (kept for backward compatibility).

Returns: DataFrame — same shape as allocation.

def history( self, period: str = '1mo', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None) -> pandas.core.frame.DataFrame:
333    def history(
334        self,
335        period: str = "1mo",
336        start: datetime | str | None = None,
337        end: datetime | str | None = None,
338    ) -> pd.DataFrame:
339        """Get historical NAV (unit price) for the fund.
340
341        Backed by the new ``fonFiyatBilgiGetir`` endpoint (TEFAS v2 API,
342        2026-04). Maximum window is **5 years** — ``period="max"`` is capped
343        at 5y. Arbitrary ``start``/``end`` ranges are supported by fetching
344        the smallest covering bucket and filtering client-side.
345
346        Args:
347            period: One of ``1d``, ``5d``, ``1mo``, ``3mo``, ``6mo``, ``ytd``,
348                ``1y``, ``3y``, ``5y``, ``max``. Ignored when ``start`` is
349                given.
350            start: Start date (string or datetime).
351            end: End date (string or datetime). Defaults to now.
352
353        Returns:
354            DataFrame indexed by ``Date`` with column ``Price``. The
355            ``FundSize`` and ``Investors`` columns are kept for backward
356            compatibility but are now NaN/0 — the new API no longer returns
357            these.
358
359        Examples:
360            >>> fund = Fund("AAK")
361            >>> fund.history(period="1mo")
362            >>> fund.history(period="5y")  # max supported
363            >>> fund.history(start="2024-01-01", end="2024-06-30")
364        """
365        start_dt = self._parse_date(start) if start else None
366        end_dt = self._parse_date(end) if end else None
367
368        return self._provider.get_history(
369            fund_code=self._fund_code,
370            period=period,
371            start=start_dt,
372            end=end_dt,
373            fund_type=self.fund_type,
374        )

Get historical NAV (unit price) for the fund.

Backed by the new fonFiyatBilgiGetir endpoint (TEFAS v2 API, 2026-04). Maximum window is 5 years — period="max" is capped at 5y. Arbitrary start/end ranges are supported by fetching the smallest covering bucket and filtering client-side.

Args: period: One of 1d, 5d, 1mo, 3mo, 6mo, ytd, 1y, 3y, 5y, max. Ignored when start is given. start: Start date (string or datetime). end: End date (string or datetime). Defaults to now.

Returns: DataFrame indexed by Date with column Price. The FundSize and Investors columns are kept for backward compatibility but are now NaN/0 — the new API no longer returns these.

Examples:

fund = Fund("AAK") fund.history(period="1mo") fund.history(period="5y") # max supported fund.history(start="2024-01-01", end="2024-06-30")

def sharpe_ratio(self, period: str = '1y', risk_free_rate: float | None = None) -> float:
387    def sharpe_ratio(self, period: str = "1y", risk_free_rate: float | None = None) -> float:
388        """
389        Calculate the Sharpe ratio for the fund.
390
391        Sharpe Ratio = (Rp - Rf) / σp
392        Where:
393        - Rp = Annualized return of the fund
394        - Rf = Risk-free rate (default: 10Y government bond yield)
395        - σp = Annualized standard deviation of returns
396
397        Args:
398            period: Period for calculation ("1y", "3y", "5y"). Default is "1y".
399            risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
400                           If None, uses current 10Y bond yield from bp.risk_free_rate().
401
402        Returns:
403            Sharpe ratio as float. Higher is better (>1 good, >2 very good, >3 excellent).
404
405        Examples:
406            >>> fund = bp.Fund("YAY")
407            >>> fund.sharpe_ratio()  # 1-year Sharpe with current risk-free rate
408            0.85
409
410            >>> fund.sharpe_ratio(period="3y")  # 3-year Sharpe
411            1.23
412
413            >>> fund.sharpe_ratio(risk_free_rate=0.25)  # Custom risk-free rate
414            0.92
415        """
416        metrics = self.risk_metrics(period=period, risk_free_rate=risk_free_rate)
417        return metrics.get("sharpe_ratio", np.nan)

Calculate the Sharpe ratio for the fund.

Sharpe Ratio = (Rp - Rf) / σp Where:

  • Rp = Annualized return of the fund
  • Rf = Risk-free rate (default: 10Y government bond yield)
  • σp = Annualized standard deviation of returns

Args: period: Period for calculation ("1y", "3y", "5y"). Default is "1y". risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%). If None, uses current 10Y bond yield from bp.risk_free_rate().

Returns: Sharpe ratio as float. Higher is better (>1 good, >2 very good, >3 excellent).

Examples:

fund = bp.Fund("YAY") fund.sharpe_ratio() # 1-year Sharpe with current risk-free rate 0.85

>>> fund.sharpe_ratio(period="3y")  # 3-year Sharpe
1.23

>>> fund.sharpe_ratio(risk_free_rate=0.25)  # Custom risk-free rate
0.92
def risk_metrics( self, period: str = '1y', risk_free_rate: float | None = None) -> dict[str, typing.Any]:
419    def risk_metrics(
420        self,
421        period: str = "1y",
422        risk_free_rate: float | None = None,
423    ) -> dict[str, Any]:
424        """
425        Calculate comprehensive risk metrics for the fund.
426
427        Args:
428            period: Period for calculation ("1y", "3y", "5y"). Default is "1y".
429            risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
430                           If None, uses current 10Y bond yield.
431
432        Returns:
433            Dictionary with risk metrics:
434            - annualized_return: Annualized return (%)
435            - annualized_volatility: Annualized standard deviation (%)
436            - sharpe_ratio: Risk-adjusted return (Rp - Rf) / σp
437            - sortino_ratio: Downside risk-adjusted return
438            - max_drawdown: Maximum peak-to-trough decline (%)
439            - risk_free_rate: Risk-free rate used (%)
440            - trading_days: Number of trading days in the period
441
442        Examples:
443            >>> fund = bp.Fund("YAY")
444            >>> metrics = fund.risk_metrics()
445            >>> print(f"Sharpe: {metrics['sharpe_ratio']:.2f}")
446            >>> print(f"Max Drawdown: {metrics['max_drawdown']:.1f}%")
447        """
448        # Get historical data
449        df = self.history(period=period)
450
451        if df.empty or len(df) < 20:
452            return {
453                "annualized_return": np.nan,
454                "annualized_volatility": np.nan,
455                "sharpe_ratio": np.nan,
456                "sortino_ratio": np.nan,
457                "max_drawdown": np.nan,
458                "risk_free_rate": np.nan,
459                "trading_days": 0,
460            }
461
462        # Calculate daily returns
463        prices = df["Price"]
464        daily_returns = prices.pct_change().dropna()
465        trading_days = len(daily_returns)
466
467        # Annualization factor (trading days per year)
468        annualization_factor = 252
469
470        # Annualized return
471        total_return = (prices.iloc[-1] / prices.iloc[0]) - 1
472        years = trading_days / annualization_factor
473        annualized_return = ((1 + total_return) ** (1 / years) - 1) * 100
474
475        # Annualized volatility
476        daily_volatility = daily_returns.std()
477        annualized_volatility = daily_volatility * np.sqrt(annualization_factor) * 100
478
479        # Get risk-free rate
480        if risk_free_rate is None:
481            try:
482                from borsapy.bond import risk_free_rate as get_rf_rate
483                rf = get_rf_rate() * 100  # Returns decimal like 0.28, convert to %
484            except Exception:
485                rf = 30.0  # Fallback: approximate Turkish 10Y yield
486        else:
487            rf = risk_free_rate * 100  # Convert decimal to percentage
488
489        # Sharpe Ratio
490        if annualized_volatility > 0:
491            sharpe = (annualized_return - rf) / annualized_volatility
492        else:
493            sharpe = np.nan
494
495        # Sortino Ratio (uses downside deviation)
496        negative_returns = daily_returns[daily_returns < 0]
497        if len(negative_returns) > 0:
498            downside_deviation = negative_returns.std() * np.sqrt(annualization_factor) * 100
499            if downside_deviation > 0:
500                sortino = (annualized_return - rf) / downside_deviation
501            else:
502                sortino = np.nan
503        else:
504            sortino = np.inf  # No negative returns
505
506        # Maximum Drawdown
507        cumulative = (1 + daily_returns).cumprod()
508        running_max = cumulative.cummax()
509        drawdowns = (cumulative - running_max) / running_max
510        max_drawdown = drawdowns.min() * 100  # Negative percentage
511
512        return {
513            "annualized_return": round(annualized_return, 2),
514            "annualized_volatility": round(annualized_volatility, 2),
515            "sharpe_ratio": round(sharpe, 2) if not np.isnan(sharpe) else np.nan,
516            "sortino_ratio": round(sortino, 2) if not np.isnan(sortino) and not np.isinf(sortino) else sortino,
517            "max_drawdown": round(max_drawdown, 2),
518            "risk_free_rate": round(rf, 2),
519            "trading_days": trading_days,
520        }

Calculate comprehensive risk metrics for the fund.

Args: period: Period for calculation ("1y", "3y", "5y"). Default is "1y". risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%). If None, uses current 10Y bond yield.

Returns: Dictionary with risk metrics: - annualized_return: Annualized return (%) - annualized_volatility: Annualized standard deviation (%) - sharpe_ratio: Risk-adjusted return (Rp - Rf) / σp - sortino_ratio: Downside risk-adjusted return - max_drawdown: Maximum peak-to-trough decline (%) - risk_free_rate: Risk-free rate used (%) - trading_days: Number of trading days in the period

Examples:

fund = bp.Fund("YAY") metrics = fund.risk_metrics() print(f"Sharpe: {metrics['sharpe_ratio']:.2f}") print(f"Max Drawdown: {metrics['max_drawdown']:.1f}%")

def get_holdings( self, api_key: str, period: str | None = None) -> pandas.core.frame.DataFrame:
522    def get_holdings(
523        self,
524        api_key: str,
525        period: str | None = None,
526    ) -> pd.DataFrame:
527        """
528        Get detailed portfolio holdings (individual securities).
529
530        Returns the specific stocks, ETFs, and funds held by this fund,
531        with their weights and ISIN codes. Data is sourced from KAP
532        "Portföy Dağılım Raporu" (Portfolio Distribution Report) disclosures.
533
534        Uses OpenRouter LLM for PDF parsing.
535
536        Args:
537            api_key: OpenRouter API key for LLM parsing.
538                    Get your free API key at: https://openrouter.ai/
539            period: Optional period in format "YYYY-MM" (e.g., "2025-12").
540                   If None, returns the most recent holdings.
541
542        Returns:
543            DataFrame with columns:
544            - symbol: Security symbol (e.g., "GOOGL", "THYAO")
545            - isin: ISIN code
546            - name: Full security name
547            - weight: Portfolio weight (%)
548            - type: Holding type ('stock', 'etf', 'fund', 'viop', etc.)
549            - country: Country ('TR', 'US', or None)
550            - value: Market value in TRY
551
552        Raises:
553            DataNotAvailableError: If holdings data not available.
554            APIError: If LLM parsing fails.
555            ImportError: If required packages are not installed.
556
557        Examples:
558            >>> fund = bp.Fund("YAY")
559            >>> fund.get_holdings(api_key="sk-or-v1-...")
560               symbol              isin                              name  weight   type country         value
561            0   GOOGL  US02079K3059             ALPHABET INC CL A    6.76  stock      US  82478088.0
562            1    AVGO  US11135F1012             BROADCOM INC          5.11  stock      US  62345678.0
563            ...
564
565            >>> # Get holdings for specific period
566            >>> fund.get_holdings(api_key="sk-or-v1-...", period="2025-12")
567
568            >>> # Filter by type
569            >>> holdings = fund.get_holdings(api_key="sk-or-v1-...")
570            >>> holdings[holdings['type'] == 'stock']
571        """
572        from borsapy._providers.kap_holdings import get_kap_holdings_provider
573
574        provider = get_kap_holdings_provider()
575        return provider.get_holdings_df(self._fund_code, api_key, period=period)

Get detailed portfolio holdings (individual securities).

Returns the specific stocks, ETFs, and funds held by this fund, with their weights and ISIN codes. Data is sourced from KAP "Portföy Dağılım Raporu" (Portfolio Distribution Report) disclosures.

Uses OpenRouter LLM for PDF parsing.

Args: api_key: OpenRouter API key for LLM parsing. Get your free API key at: https://openrouter.ai/ period: Optional period in format "YYYY-MM" (e.g., "2025-12"). If None, returns the most recent holdings.

Returns: DataFrame with columns: - symbol: Security symbol (e.g., "GOOGL", "THYAO") - isin: ISIN code - name: Full security name - weight: Portfolio weight (%) - type: Holding type ('stock', 'etf', 'fund', 'viop', etc.) - country: Country ('TR', 'US', or None) - value: Market value in TRY

Raises: DataNotAvailableError: If holdings data not available. APIError: If LLM parsing fails. ImportError: If required packages are not installed.

Examples:

fund = bp.Fund("YAY") fund.get_holdings(api_key="sk-or-v1-...") symbol isin name weight type country value 0 GOOGL US02079K3059 ALPHABET INC CL A 6.76 stock US 82478088.0 1 AVGO US11135F1012 BROADCOM INC 5.11 stock US 62345678.0 ...

>>> # Get holdings for specific period
>>> fund.get_holdings(api_key="sk-or-v1-...", period="2025-12")

>>> # Filter by type
>>> holdings = fund.get_holdings(api_key="sk-or-v1-...")
>>> holdings[holdings['type'] == 'stock']
class Portfolio(borsapy.technical.TechnicalMixin):
 120class Portfolio(TechnicalMixin):
 121    """
 122    Multi-asset portfolio management with performance tracking and risk metrics.
 123
 124    Supports 4 asset types:
 125    - stock: BIST stocks via Ticker class
 126    - fx: Currencies, metals, commodities via FX class
 127    - crypto: Cryptocurrencies via Crypto class
 128    - fund: TEFAS mutual funds via Fund class
 129
 130    Examples:
 131        >>> import borsapy as bp
 132        >>> p = bp.Portfolio()
 133        >>> p.add("THYAO", shares=100, cost=280)
 134        >>> p.add("gram-altin", shares=5, asset_type="fx")
 135        >>> p.add("YAY", shares=500, asset_type="fund")
 136        >>> p.set_benchmark("XU100")
 137        >>> print(p.holdings)
 138        >>> print(f"Value: {p.value:,.2f} TL")
 139        >>> print(f"Sharpe: {p.risk_metrics()['sharpe_ratio']:.2f}")
 140    """
 141
 142    def __init__(self, benchmark: str = "XU100"):
 143        """
 144        Initialize an empty portfolio.
 145
 146        Args:
 147            benchmark: Index symbol for beta/alpha calculations.
 148                       Default is XU100 (BIST 100).
 149        """
 150        self._holdings: dict[str, Holding] = {}
 151        self._asset_cache: dict[str, Ticker | FX | Crypto | Fund] = {}
 152        self._benchmark = benchmark
 153        self._target_weights: dict[str, float] = {}
 154
 155    # === Asset Management ===
 156
 157    def add(
 158        self,
 159        symbol: str,
 160        shares: float,
 161        cost: float | None = None,
 162        asset_type: str | None = None,
 163        purchase_date: str | date | datetime | None = None,
 164    ) -> "Portfolio":
 165        """
 166        Add an asset to the portfolio.
 167
 168        Args:
 169            symbol: Asset symbol (THYAO, USD, BTCTRY, AAK, etc.)
 170            shares: Number of shares/units.
 171            cost: Cost per share/unit. If None, uses current price.
 172            asset_type: Asset type override. Auto-detected if None.
 173                        Valid values: "stock", "fx", "crypto", "fund"
 174            purchase_date: Date when the asset was purchased.
 175                          Accepts string (YYYY-MM-DD), date, or datetime.
 176                          If None, defaults to today.
 177
 178        Returns:
 179            Self for method chaining.
 180
 181        Examples:
 182            >>> p = Portfolio()
 183            >>> p.add("THYAO", shares=100, cost=280)  # Stock with cost
 184            >>> p.add("GARAN", shares=200)  # Stock at current price
 185            >>> p.add("gram-altin", shares=5, asset_type="fx")  # Metal
 186            >>> p.add("YAY", shares=500, asset_type="fund")  # Mutual fund
 187            >>> p.add("ASELS", shares=50, cost=120, purchase_date="2024-01-15")
 188        """
 189        symbol = symbol.upper() if asset_type != "fx" else symbol
 190
 191        # Detect or validate asset type
 192        if asset_type is None:
 193            detected_type = _detect_asset_type(symbol)
 194        else:
 195            detected_type = asset_type  # type: ignore
 196
 197        # Get current price if cost not provided
 198        if cost is None:
 199            asset = self._get_or_create_asset(symbol, detected_type)
 200            cost = self._get_current_price(asset)
 201
 202        # Parse purchase_date
 203        parsed_date: date | None = None
 204        if purchase_date is not None:
 205            if isinstance(purchase_date, str):
 206                parsed_date = datetime.strptime(purchase_date, "%Y-%m-%d").date()
 207            elif isinstance(purchase_date, datetime):
 208                parsed_date = purchase_date.date()
 209            elif isinstance(purchase_date, date):
 210                parsed_date = purchase_date
 211        else:
 212            parsed_date = date.today()
 213
 214        self._holdings[symbol] = Holding(
 215            symbol=symbol,
 216            shares=shares,
 217            cost_per_share=cost,
 218            asset_type=detected_type,
 219            purchase_date=parsed_date,
 220        )
 221
 222        return self
 223
 224    def remove(self, symbol: str) -> "Portfolio":
 225        """
 226        Remove an asset from the portfolio.
 227
 228        Args:
 229            symbol: Asset symbol to remove.
 230
 231        Returns:
 232            Self for method chaining.
 233        """
 234        symbol_upper = symbol.upper()
 235
 236        # Try both original and uppercase
 237        if symbol in self._holdings:
 238            del self._holdings[symbol]
 239            self._asset_cache.pop(symbol, None)
 240        elif symbol_upper in self._holdings:
 241            del self._holdings[symbol_upper]
 242            self._asset_cache.pop(symbol_upper, None)
 243
 244        return self
 245
 246    def update(
 247        self,
 248        symbol: str,
 249        shares: float | None = None,
 250        cost: float | None = None,
 251    ) -> "Portfolio":
 252        """
 253        Update an existing holding.
 254
 255        Args:
 256            symbol: Asset symbol.
 257            shares: New share count. If None, keeps existing.
 258            cost: New cost per share. If None, keeps existing.
 259
 260        Returns:
 261            Self for method chaining.
 262        """
 263        if symbol not in self._holdings:
 264            symbol = symbol.upper()
 265        if symbol not in self._holdings:
 266            raise KeyError(f"Symbol {symbol} not in portfolio")
 267
 268        holding = self._holdings[symbol]
 269        if shares is not None:
 270            holding.shares = shares
 271        if cost is not None:
 272            holding.cost_per_share = cost
 273
 274        return self
 275
 276    def clear(self) -> "Portfolio":
 277        """
 278        Remove all holdings from the portfolio.
 279
 280        Returns:
 281            Self for method chaining.
 282        """
 283        self._holdings.clear()
 284        self._asset_cache.clear()
 285        return self
 286
 287    def set_benchmark(self, index: str) -> "Portfolio":
 288        """
 289        Set the benchmark index for beta/alpha calculations.
 290
 291        Args:
 292            index: Index symbol (XU100, XU030, XK030, etc.)
 293
 294        Returns:
 295            Self for method chaining.
 296        """
 297        self._benchmark = index
 298        return self
 299
 300    # === Rebalancing ===
 301
 302    def set_target_weights(self, weights: dict[str, float]) -> "Portfolio":
 303        """Set target allocation weights for rebalancing.
 304
 305        Args:
 306            weights: Dict of symbol -> target weight (0.0 to 1.0 scale).
 307                     Must sum to approximately 1.0 (tolerance: 0.01).
 308
 309        Returns:
 310            Self for method chaining.
 311
 312        Raises:
 313            ValueError: If weights don't sum to ~1.0 or contain invalid values.
 314
 315        Examples:
 316            >>> p = Portfolio()
 317            >>> p.add("THYAO", shares=100, cost=280)
 318            >>> p.add("GARAN", shares=200, cost=50)
 319            >>> p.set_target_weights({"THYAO": 0.60, "GARAN": 0.40})
 320        """
 321        total = sum(weights.values())
 322        if abs(total - 1.0) > 0.01:
 323            raise ValueError(
 324                f"Target weights must sum to ~1.0, got {total:.4f}"
 325            )
 326        for symbol, weight in weights.items():
 327            if weight < 0 or weight > 1:
 328                raise ValueError(
 329                    f"Weight for {symbol} must be between 0.0 and 1.0, got {weight}"
 330                )
 331        self._target_weights = dict(weights)
 332        return self
 333
 334    @property
 335    def target_weights(self) -> dict[str, float]:
 336        """Get current target allocation weights."""
 337        return dict(self._target_weights)
 338
 339    def drift(self) -> pd.DataFrame:
 340        """Calculate drift between current and target weights.
 341
 342        Returns:
 343            DataFrame with columns:
 344            - symbol: Asset symbol
 345            - current_weight: Current portfolio weight (0-1 scale)
 346            - target_weight: Target allocation weight (0-1 scale)
 347            - drift: Absolute drift (current - target)
 348            - drift_pct: Drift as percentage points
 349
 350        Raises:
 351            ValueError: If target weights are not set.
 352        """
 353        if not self._target_weights:
 354            raise ValueError(
 355                "Target weights not set. Call set_target_weights() first."
 356            )
 357
 358        current_weights = self.weights  # dict[str, float] on 0-1 scale
 359        rows = []
 360
 361        # Include all symbols from both current holdings and targets
 362        all_symbols = set(current_weights.keys()) | set(self._target_weights.keys())
 363
 364        for symbol in sorted(all_symbols):
 365            current = current_weights.get(symbol, 0.0)
 366            target = self._target_weights.get(symbol, 0.0)
 367            drift_val = current - target
 368            rows.append({
 369                "symbol": symbol,
 370                "current_weight": round(current, 4),
 371                "target_weight": round(target, 4),
 372                "drift": round(drift_val, 4),
 373                "drift_pct": round(drift_val * 100, 2),
 374            })
 375
 376        return pd.DataFrame(rows)
 377
 378    def rebalance_plan(self, threshold: float = 0.0) -> pd.DataFrame:
 379        """Calculate trades needed to rebalance portfolio.
 380
 381        Args:
 382            threshold: Minimum drift (0-1 scale) to trigger a trade.
 383                      E.g., 0.02 = ignore drifts less than 2%.
 384
 385        Returns:
 386            DataFrame with columns:
 387            - symbol: Asset symbol
 388            - current_shares: Current number of shares
 389            - target_shares: Target number of shares
 390            - delta_shares: Shares to buy (+) or sell (-)
 391            - delta_value: Approximate trade value in TL
 392            - action: "BUY", "SELL", or "HOLD"
 393
 394        Raises:
 395            ValueError: If target weights are not set.
 396        """
 397        if not self._target_weights:
 398            raise ValueError(
 399                "Target weights not set. Call set_target_weights() first."
 400            )
 401
 402        total_value = self.value
 403        if total_value == 0:
 404            return pd.DataFrame(
 405                columns=["symbol", "current_shares", "target_shares",
 406                         "delta_shares", "delta_value", "action"]
 407            )
 408
 409        rows = []
 410        all_symbols = set(self._holdings.keys()) | set(self._target_weights.keys())
 411
 412        for symbol in sorted(all_symbols):
 413            holding = self._holdings.get(symbol)
 414            target_weight = self._target_weights.get(symbol, 0.0)
 415
 416            # Get current price
 417            if holding:
 418                asset = self._get_or_create_asset(symbol, holding.asset_type)
 419            else:
 420                # Symbol in targets but not in holdings - detect type
 421                detected_type = _detect_asset_type(symbol)
 422                asset = self._get_or_create_asset(symbol, detected_type)
 423            current_price = self._get_current_price(asset)
 424
 425            current_shares = holding.shares if holding else 0.0
 426            current_value = current_shares * current_price
 427            current_weight = current_value / total_value if total_value else 0.0
 428
 429            # Check threshold
 430            if abs(current_weight - target_weight) < threshold:
 431                rows.append({
 432                    "symbol": symbol,
 433                    "current_shares": current_shares,
 434                    "target_shares": current_shares,
 435                    "delta_shares": 0.0,
 436                    "delta_value": 0.0,
 437                    "action": "HOLD",
 438                })
 439                continue
 440
 441            # Calculate target shares
 442            target_value = total_value * target_weight
 443            if current_price > 0:
 444                target_shares = target_value / current_price
 445            else:
 446                target_shares = 0.0
 447
 448            # Round stock shares to integers
 449            asset_type = holding.asset_type if holding else _detect_asset_type(symbol)
 450            if asset_type == "stock":
 451                target_shares = round(target_shares)
 452
 453            delta_shares = target_shares - current_shares
 454            delta_value = delta_shares * current_price
 455
 456            if delta_shares > 0.001:
 457                action = "BUY"
 458            elif delta_shares < -0.001:
 459                action = "SELL"
 460            else:
 461                action = "HOLD"
 462
 463            rows.append({
 464                "symbol": symbol,
 465                "current_shares": current_shares,
 466                "target_shares": round(target_shares, 4),
 467                "delta_shares": round(delta_shares, 4),
 468                "delta_value": round(delta_value, 2),
 469                "action": action,
 470            })
 471
 472        return pd.DataFrame(rows)
 473
 474    def rebalance(self, threshold: float = 0.0, dry_run: bool = False) -> pd.DataFrame:
 475        """Execute rebalance by updating share counts.
 476
 477        Updates the portfolio holdings to match target weights.
 478        Stock shares are rounded to integers; crypto and fund shares
 479        remain fractional.
 480
 481        Args:
 482            threshold: Minimum drift (0-1 scale) to trigger a trade.
 483            dry_run: If True, return the plan without executing.
 484
 485        Returns:
 486            DataFrame with the rebalance plan (same as rebalance_plan()).
 487
 488        Raises:
 489            ValueError: If target weights are not set.
 490        """
 491        plan = self.rebalance_plan(threshold=threshold)
 492
 493        if dry_run or plan.empty:
 494            return plan
 495
 496        for _, row in plan.iterrows():
 497            symbol = row["symbol"]
 498            action = row["action"]
 499            target_shares = row["target_shares"]
 500
 501            if action == "HOLD":
 502                continue
 503
 504            if symbol in self._holdings:
 505                if target_shares <= 0:
 506                    self.remove(symbol)
 507                else:
 508                    self._holdings[symbol].shares = target_shares
 509            elif target_shares > 0:
 510                # New holding needed
 511                detected_type = _detect_asset_type(symbol)
 512                asset = self._get_or_create_asset(symbol, detected_type)
 513                price = self._get_current_price(asset)
 514                self._holdings[symbol] = Holding(
 515                    symbol=symbol,
 516                    shares=target_shares,
 517                    cost_per_share=price,
 518                    asset_type=detected_type,
 519                    purchase_date=date.today(),
 520                )
 521
 522        return plan
 523
 524    # === Properties ===
 525
 526    @property
 527    def holdings(self) -> pd.DataFrame:
 528        """
 529        Get all holdings as a DataFrame.
 530
 531        Returns:
 532            DataFrame with columns:
 533            - symbol: Asset symbol
 534            - shares: Number of shares
 535            - cost: Cost per share
 536            - current_price: Current price
 537            - value: Current value (shares * price)
 538            - weight: Portfolio weight (%)
 539            - pnl: Profit/loss (TL)
 540            - pnl_pct: Profit/loss (%)
 541            - asset_type: Asset type
 542            - purchase_date: Date when asset was purchased
 543            - holding_days: Number of days since purchase
 544        """
 545        if not self._holdings:
 546            return pd.DataFrame(
 547                columns=[
 548                    "symbol", "shares", "cost", "current_price",
 549                    "value", "weight", "pnl", "pnl_pct", "asset_type",
 550                    "purchase_date", "holding_days"
 551                ]
 552            )
 553
 554        rows = []
 555        total_value = self.value
 556        today = date.today()
 557
 558        for symbol, holding in self._holdings.items():
 559            asset = self._get_or_create_asset(symbol, holding.asset_type)
 560            current_price = self._get_current_price(asset)
 561            value = holding.shares * current_price
 562            cost_basis = (holding.shares * holding.cost_per_share) if holding.cost_per_share else 0
 563            pnl = value - cost_basis if cost_basis else 0
 564            pnl_pct = (pnl / cost_basis * 100) if cost_basis else 0
 565            weight = (value / total_value * 100) if total_value else 0
 566
 567            # Calculate holding days
 568            holding_days = None
 569            if holding.purchase_date:
 570                holding_days = (today - holding.purchase_date).days
 571
 572            rows.append({
 573                "symbol": symbol,
 574                "shares": holding.shares,
 575                "cost": holding.cost_per_share,
 576                "current_price": current_price,
 577                "value": value,
 578                "weight": round(weight, 2),
 579                "pnl": round(pnl, 2),
 580                "pnl_pct": round(pnl_pct, 2),
 581                "asset_type": holding.asset_type,
 582                "purchase_date": holding.purchase_date,
 583                "holding_days": holding_days,
 584            })
 585
 586        return pd.DataFrame(rows)
 587
 588    @property
 589    def symbols(self) -> list[str]:
 590        """Get list of symbols in portfolio."""
 591        return list(self._holdings.keys())
 592
 593    @property
 594    def value(self) -> float:
 595        """Get total portfolio value in TL."""
 596        total = 0.0
 597        for symbol, holding in self._holdings.items():
 598            asset = self._get_or_create_asset(symbol, holding.asset_type)
 599            price = self._get_current_price(asset)
 600            total += holding.shares * price
 601        return total
 602
 603    @property
 604    def cost(self) -> float:
 605        """Get total portfolio cost basis in TL."""
 606        total = 0.0
 607        for holding in self._holdings.values():
 608            if holding.cost_per_share:
 609                total += holding.shares * holding.cost_per_share
 610        return total
 611
 612    @property
 613    def pnl(self) -> float:
 614        """Get total profit/loss in TL."""
 615        return self.value - self.cost
 616
 617    @property
 618    def pnl_pct(self) -> float:
 619        """Get total profit/loss as percentage."""
 620        cost = self.cost
 621        if cost == 0:
 622            return 0.0
 623        return (self.pnl / cost) * 100
 624
 625    @property
 626    def weights(self) -> dict[str, float]:
 627        """Get portfolio weights as dictionary."""
 628        total_value = self.value
 629        if total_value == 0:
 630            return {}
 631
 632        result = {}
 633        for symbol, holding in self._holdings.items():
 634            asset = self._get_or_create_asset(symbol, holding.asset_type)
 635            price = self._get_current_price(asset)
 636            value = holding.shares * price
 637            result[symbol] = round(value / total_value, 4)
 638        return result
 639
 640    # === Performance ===
 641
 642    def history(self, period: str = "1y") -> pd.DataFrame:
 643        """
 644        Get historical portfolio value based on current holdings.
 645
 646        Note: Uses current share counts - does not track historical trades.
 647        When purchase_date is set for a holding, only data from that date
 648        onwards is included in the portfolio value calculation.
 649
 650        Args:
 651            period: Period for historical data (1d, 5d, 1mo, 3mo, 6mo, 1y).
 652
 653        Returns:
 654            DataFrame with columns: Value, Daily_Return.
 655            Index is Date.
 656        """
 657        if not self._holdings:
 658            return pd.DataFrame(columns=["Value", "Daily_Return"])
 659
 660        all_prices = {}
 661        for symbol, holding in self._holdings.items():
 662            asset = self._get_or_create_asset(symbol, holding.asset_type)
 663            try:
 664                hist = asset.history(period=period)
 665                if hist.empty:
 666                    continue
 667
 668                # Filter by purchase_date if set
 669                if holding.purchase_date:
 670                    # Handle both timezone-aware and timezone-naive indices
 671                    if hasattr(hist.index, 'tz') and hist.index.tz is not None:
 672                        hist = hist[hist.index.date >= holding.purchase_date]
 673                    else:
 674                        hist = hist[hist.index >= pd.Timestamp(holding.purchase_date)]
 675
 676                if hist.empty:
 677                    continue
 678
 679                # Use Close for stocks/index, Price for funds
 680                price_col = "Close" if "Close" in hist.columns else "Price"
 681                all_prices[symbol] = hist[price_col] * holding.shares
 682            except Exception:
 683                continue
 684
 685        if not all_prices:
 686            return pd.DataFrame(columns=["Value", "Daily_Return"])
 687
 688        df = pd.DataFrame(all_prices)
 689        df = df.dropna(how="all")
 690        df["Value"] = df.sum(axis=1)
 691        df["Daily_Return"] = df["Value"].pct_change()
 692        return df[["Value", "Daily_Return"]]
 693
 694    @property
 695    def performance(self) -> dict[str, float]:
 696        """
 697        Get portfolio performance summary.
 698
 699        Returns:
 700            Dictionary with:
 701            - total_return: Total return (%)
 702            - annualized_return: Annualized return (%)
 703            - total_value: Current value (TL)
 704            - total_cost: Total cost (TL)
 705            - total_pnl: Profit/loss (TL)
 706        """
 707        return {
 708            "total_return": self.pnl_pct,
 709            "annualized_return": np.nan,  # Calculated in risk_metrics
 710            "total_value": self.value,
 711            "total_cost": self.cost,
 712            "total_pnl": self.pnl,
 713        }
 714
 715    # === Risk Metrics ===
 716
 717    def risk_metrics(
 718        self,
 719        period: str = "1y",
 720        risk_free_rate: float | None = None,
 721    ) -> dict[str, Any]:
 722        """
 723        Calculate comprehensive risk metrics.
 724
 725        Args:
 726            period: Period for calculation (1y, 3mo, 6mo).
 727            risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
 728                           If None, uses current 10Y bond yield.
 729
 730        Returns:
 731            Dictionary with:
 732            - annualized_return: Annualized return (%)
 733            - annualized_volatility: Annualized volatility (%)
 734            - sharpe_ratio: Risk-adjusted return
 735            - sortino_ratio: Downside risk-adjusted return
 736            - max_drawdown: Maximum drawdown (%)
 737            - beta: Beta vs benchmark
 738            - alpha: Alpha vs benchmark (%)
 739            - risk_free_rate: Risk-free rate used (%)
 740            - trading_days: Number of trading days
 741        """
 742        df = self.history(period=period)
 743
 744        if df.empty or len(df) < 20:
 745            return {
 746                "annualized_return": np.nan,
 747                "annualized_volatility": np.nan,
 748                "sharpe_ratio": np.nan,
 749                "sortino_ratio": np.nan,
 750                "max_drawdown": np.nan,
 751                "beta": np.nan,
 752                "alpha": np.nan,
 753                "risk_free_rate": np.nan,
 754                "trading_days": 0,
 755            }
 756
 757        daily_returns = df["Daily_Return"].dropna()
 758        trading_days = len(daily_returns)
 759        annualization = 252
 760
 761        # Annualized return
 762        total_return = (df["Value"].iloc[-1] / df["Value"].iloc[0]) - 1
 763        years = trading_days / annualization
 764        ann_return = ((1 + total_return) ** (1 / years) - 1) * 100
 765
 766        # Annualized volatility
 767        daily_volatility = daily_returns.std()
 768        ann_volatility = daily_volatility * np.sqrt(annualization) * 100
 769
 770        # Get risk-free rate
 771        if risk_free_rate is None:
 772            try:
 773                from borsapy.bond import risk_free_rate as get_rf_rate
 774                rf = get_rf_rate() * 100  # Convert to percentage
 775            except Exception:
 776                rf = 30.0  # Fallback
 777        else:
 778            rf = risk_free_rate * 100
 779
 780        # Sharpe Ratio
 781        if ann_volatility > 0:
 782            sharpe = (ann_return - rf) / ann_volatility
 783        else:
 784            sharpe = np.nan
 785
 786        # Sortino Ratio (downside deviation)
 787        negative_returns = daily_returns[daily_returns < 0]
 788        if len(negative_returns) > 0:
 789            downside_deviation = negative_returns.std() * np.sqrt(annualization) * 100
 790            if downside_deviation > 0:
 791                sortino = (ann_return - rf) / downside_deviation
 792            else:
 793                sortino = np.nan
 794        else:
 795            sortino = np.inf  # No negative returns
 796
 797        # Maximum Drawdown
 798        cumulative = (1 + daily_returns).cumprod()
 799        running_max = cumulative.cummax()
 800        drawdowns = (cumulative - running_max) / running_max
 801        max_drawdown = drawdowns.min() * 100
 802
 803        # Beta and Alpha (vs benchmark)
 804        beta = np.nan
 805        alpha = np.nan
 806
 807        try:
 808            bench = Index(self._benchmark)
 809            bench_hist = bench.history(period=period)
 810            if not bench_hist.empty:
 811                bench_returns = bench_hist["Close"].pct_change().dropna()
 812
 813                # Align dates
 814                common_dates = daily_returns.index.intersection(bench_returns.index)
 815                if len(common_dates) >= 20:
 816                    port_ret = daily_returns.loc[common_dates]
 817                    bench_ret = bench_returns.loc[common_dates]
 818
 819                    # Beta = Cov(Rp, Rm) / Var(Rm)
 820                    covariance = port_ret.cov(bench_ret)
 821                    variance = bench_ret.var()
 822                    if variance > 0:
 823                        beta = covariance / variance
 824
 825                        # Alpha = Rp - Rf - Beta * (Rm - Rf)
 826                        bench_total = (bench_hist["Close"].iloc[-1] / bench_hist["Close"].iloc[0]) - 1
 827                        bench_ann = ((1 + bench_total) ** (1 / years) - 1) * 100
 828                        alpha = ann_return - rf - beta * (bench_ann - rf)
 829        except Exception:
 830            pass
 831
 832        return {
 833            "annualized_return": round(ann_return, 2),
 834            "annualized_volatility": round(ann_volatility, 2),
 835            "sharpe_ratio": round(sharpe, 2) if not np.isnan(sharpe) else np.nan,
 836            "sortino_ratio": round(sortino, 2) if not np.isnan(sortino) and not np.isinf(sortino) else sortino,
 837            "max_drawdown": round(max_drawdown, 2),
 838            "beta": round(beta, 2) if not np.isnan(beta) else np.nan,
 839            "alpha": round(alpha, 2) if not np.isnan(alpha) else np.nan,
 840            "risk_free_rate": round(rf, 2),
 841            "trading_days": trading_days,
 842        }
 843
 844    def sharpe_ratio(self, period: str = "1y") -> float:
 845        """
 846        Calculate Sharpe ratio.
 847
 848        Args:
 849            period: Period for calculation.
 850
 851        Returns:
 852            Sharpe ratio.
 853        """
 854        return self.risk_metrics(period=period).get("sharpe_ratio", np.nan)
 855
 856    def sortino_ratio(self, period: str = "1y") -> float:
 857        """
 858        Calculate Sortino ratio.
 859
 860        Args:
 861            period: Period for calculation.
 862
 863        Returns:
 864            Sortino ratio.
 865        """
 866        return self.risk_metrics(period=period).get("sortino_ratio", np.nan)
 867
 868    def beta(self, benchmark: str | None = None, period: str = "1y") -> float:
 869        """
 870        Calculate beta vs benchmark.
 871
 872        Args:
 873            benchmark: Benchmark index. Uses portfolio default if None.
 874            period: Period for calculation.
 875
 876        Returns:
 877            Beta coefficient.
 878        """
 879        if benchmark:
 880            old_bench = self._benchmark
 881            self._benchmark = benchmark
 882            result = self.risk_metrics(period=period).get("beta", np.nan)
 883            self._benchmark = old_bench
 884            return result
 885        return self.risk_metrics(period=period).get("beta", np.nan)
 886
 887    def correlation_matrix(self, period: str = "1y") -> pd.DataFrame:
 888        """
 889        Calculate correlation matrix between holdings.
 890
 891        Args:
 892            period: Period for calculation.
 893
 894        Returns:
 895            DataFrame with correlation coefficients.
 896        """
 897        if len(self._holdings) < 2:
 898            return pd.DataFrame()
 899
 900        returns_dict = {}
 901        for symbol, holding in self._holdings.items():
 902            try:
 903                asset = self._get_or_create_asset(symbol, holding.asset_type)
 904                hist = asset.history(period=period)
 905                if hist.empty:
 906                    continue
 907                price_col = "Close" if "Close" in hist.columns else "Price"
 908                returns_dict[symbol] = hist[price_col].pct_change()
 909            except Exception:
 910                continue
 911
 912        if len(returns_dict) < 2:
 913            return pd.DataFrame()
 914
 915        df = pd.DataFrame(returns_dict).dropna()
 916        return df.corr()
 917
 918    # === Import/Export ===
 919
 920    def to_dict(self) -> dict[str, Any]:
 921        """
 922        Export portfolio to dictionary.
 923
 924        Returns:
 925            Dictionary with portfolio data.
 926        """
 927        result: dict[str, Any] = {
 928            "benchmark": self._benchmark,
 929            "holdings": [
 930                {
 931                    "symbol": h.symbol,
 932                    "shares": h.shares,
 933                    "cost_per_share": h.cost_per_share,
 934                    "asset_type": h.asset_type,
 935                    "purchase_date": h.purchase_date.isoformat() if h.purchase_date else None,
 936                }
 937                for h in self._holdings.values()
 938            ],
 939        }
 940        if self._target_weights:
 941            result["target_weights"] = dict(self._target_weights)
 942        return result
 943
 944    @classmethod
 945    def from_dict(cls, data: dict[str, Any]) -> "Portfolio":
 946        """
 947        Create portfolio from dictionary.
 948
 949        Args:
 950            data: Dictionary with portfolio data.
 951
 952        Returns:
 953            Portfolio instance.
 954        """
 955        portfolio = cls(benchmark=data.get("benchmark", "XU100"))
 956        for h in data.get("holdings", []):
 957            # Parse purchase_date from ISO string
 958            purchase_date = None
 959            if h.get("purchase_date"):
 960                purchase_date = date.fromisoformat(h["purchase_date"])
 961
 962            portfolio.add(
 963                symbol=h["symbol"],
 964                shares=h["shares"],
 965                cost=h.get("cost_per_share"),
 966                asset_type=h.get("asset_type"),
 967                purchase_date=purchase_date,
 968            )
 969        # Restore target weights if present
 970        if "target_weights" in data:
 971            portfolio._target_weights = dict(data["target_weights"])
 972        return portfolio
 973
 974    # === Private Methods ===
 975
 976    def _get_or_create_asset(
 977        self, symbol: str, asset_type: AssetType
 978    ) -> Ticker | FX | Crypto | Fund:
 979        """Get or create asset instance from cache."""
 980        cache_key = f"{symbol}_{asset_type}"
 981        if cache_key not in self._asset_cache:
 982            self._asset_cache[cache_key] = _get_asset(symbol, asset_type)
 983        return self._asset_cache[cache_key]
 984
 985    def _get_current_price(self, asset: Ticker | FX | Crypto | Fund) -> float:
 986        """Get current price from asset."""
 987        try:
 988            if isinstance(asset, Ticker):
 989                return asset.fast_info.last_price or 0
 990            elif isinstance(asset, Crypto):
 991                return asset.fast_info.last_price or 0
 992            elif isinstance(asset, FX):
 993                current = asset.current
 994                return current.get("last", 0) if current else 0
 995            elif isinstance(asset, Fund):
 996                info = asset.info
 997                return info.get("price", 0) if info else 0
 998        except Exception:
 999            pass
1000        return 0
1001
1002    def __repr__(self) -> str:
1003        n = len(self._holdings)
1004        value = self.value
1005        return f"Portfolio({n} holdings, {value:,.2f} TL)"
1006
1007    def __len__(self) -> int:
1008        return len(self._holdings)

Multi-asset portfolio management with performance tracking and risk metrics.

Supports 4 asset types:

  • stock: BIST stocks via Ticker class
  • fx: Currencies, metals, commodities via FX class
  • crypto: Cryptocurrencies via Crypto class
  • fund: TEFAS mutual funds via Fund class

Examples:

import borsapy as bp p = bp.Portfolio() p.add("THYAO", shares=100, cost=280) p.add("gram-altin", shares=5, asset_type="fx") p.add("YAY", shares=500, asset_type="fund") p.set_benchmark("XU100") print(p.holdings) print(f"Value: {p.value:,.2f} TL") print(f"Sharpe: {p.risk_metrics()['sharpe_ratio']:.2f}")

Portfolio(benchmark: str = 'XU100')
142    def __init__(self, benchmark: str = "XU100"):
143        """
144        Initialize an empty portfolio.
145
146        Args:
147            benchmark: Index symbol for beta/alpha calculations.
148                       Default is XU100 (BIST 100).
149        """
150        self._holdings: dict[str, Holding] = {}
151        self._asset_cache: dict[str, Ticker | FX | Crypto | Fund] = {}
152        self._benchmark = benchmark
153        self._target_weights: dict[str, float] = {}

Initialize an empty portfolio.

Args: benchmark: Index symbol for beta/alpha calculations. Default is XU100 (BIST 100).

def add( self, symbol: str, shares: float, cost: float | None = None, asset_type: str | None = None, purchase_date: str | datetime.date | datetime.datetime | None = None) -> Portfolio:
157    def add(
158        self,
159        symbol: str,
160        shares: float,
161        cost: float | None = None,
162        asset_type: str | None = None,
163        purchase_date: str | date | datetime | None = None,
164    ) -> "Portfolio":
165        """
166        Add an asset to the portfolio.
167
168        Args:
169            symbol: Asset symbol (THYAO, USD, BTCTRY, AAK, etc.)
170            shares: Number of shares/units.
171            cost: Cost per share/unit. If None, uses current price.
172            asset_type: Asset type override. Auto-detected if None.
173                        Valid values: "stock", "fx", "crypto", "fund"
174            purchase_date: Date when the asset was purchased.
175                          Accepts string (YYYY-MM-DD), date, or datetime.
176                          If None, defaults to today.
177
178        Returns:
179            Self for method chaining.
180
181        Examples:
182            >>> p = Portfolio()
183            >>> p.add("THYAO", shares=100, cost=280)  # Stock with cost
184            >>> p.add("GARAN", shares=200)  # Stock at current price
185            >>> p.add("gram-altin", shares=5, asset_type="fx")  # Metal
186            >>> p.add("YAY", shares=500, asset_type="fund")  # Mutual fund
187            >>> p.add("ASELS", shares=50, cost=120, purchase_date="2024-01-15")
188        """
189        symbol = symbol.upper() if asset_type != "fx" else symbol
190
191        # Detect or validate asset type
192        if asset_type is None:
193            detected_type = _detect_asset_type(symbol)
194        else:
195            detected_type = asset_type  # type: ignore
196
197        # Get current price if cost not provided
198        if cost is None:
199            asset = self._get_or_create_asset(symbol, detected_type)
200            cost = self._get_current_price(asset)
201
202        # Parse purchase_date
203        parsed_date: date | None = None
204        if purchase_date is not None:
205            if isinstance(purchase_date, str):
206                parsed_date = datetime.strptime(purchase_date, "%Y-%m-%d").date()
207            elif isinstance(purchase_date, datetime):
208                parsed_date = purchase_date.date()
209            elif isinstance(purchase_date, date):
210                parsed_date = purchase_date
211        else:
212            parsed_date = date.today()
213
214        self._holdings[symbol] = Holding(
215            symbol=symbol,
216            shares=shares,
217            cost_per_share=cost,
218            asset_type=detected_type,
219            purchase_date=parsed_date,
220        )
221
222        return self

Add an asset to the portfolio.

Args: symbol: Asset symbol (THYAO, USD, BTCTRY, AAK, etc.) shares: Number of shares/units. cost: Cost per share/unit. If None, uses current price. asset_type: Asset type override. Auto-detected if None. Valid values: "stock", "fx", "crypto", "fund" purchase_date: Date when the asset was purchased. Accepts string (YYYY-MM-DD), date, or datetime. If None, defaults to today.

Returns: Self for method chaining.

Examples:

p = Portfolio() p.add("THYAO", shares=100, cost=280) # Stock with cost p.add("GARAN", shares=200) # Stock at current price p.add("gram-altin", shares=5, asset_type="fx") # Metal p.add("YAY", shares=500, asset_type="fund") # Mutual fund p.add("ASELS", shares=50, cost=120, purchase_date="2024-01-15")

def remove(self, symbol: str) -> Portfolio:
224    def remove(self, symbol: str) -> "Portfolio":
225        """
226        Remove an asset from the portfolio.
227
228        Args:
229            symbol: Asset symbol to remove.
230
231        Returns:
232            Self for method chaining.
233        """
234        symbol_upper = symbol.upper()
235
236        # Try both original and uppercase
237        if symbol in self._holdings:
238            del self._holdings[symbol]
239            self._asset_cache.pop(symbol, None)
240        elif symbol_upper in self._holdings:
241            del self._holdings[symbol_upper]
242            self._asset_cache.pop(symbol_upper, None)
243
244        return self

Remove an asset from the portfolio.

Args: symbol: Asset symbol to remove.

Returns: Self for method chaining.

def update( self, symbol: str, shares: float | None = None, cost: float | None = None) -> Portfolio:
246    def update(
247        self,
248        symbol: str,
249        shares: float | None = None,
250        cost: float | None = None,
251    ) -> "Portfolio":
252        """
253        Update an existing holding.
254
255        Args:
256            symbol: Asset symbol.
257            shares: New share count. If None, keeps existing.
258            cost: New cost per share. If None, keeps existing.
259
260        Returns:
261            Self for method chaining.
262        """
263        if symbol not in self._holdings:
264            symbol = symbol.upper()
265        if symbol not in self._holdings:
266            raise KeyError(f"Symbol {symbol} not in portfolio")
267
268        holding = self._holdings[symbol]
269        if shares is not None:
270            holding.shares = shares
271        if cost is not None:
272            holding.cost_per_share = cost
273
274        return self

Update an existing holding.

Args: symbol: Asset symbol. shares: New share count. If None, keeps existing. cost: New cost per share. If None, keeps existing.

Returns: Self for method chaining.

def clear(self) -> Portfolio:
276    def clear(self) -> "Portfolio":
277        """
278        Remove all holdings from the portfolio.
279
280        Returns:
281            Self for method chaining.
282        """
283        self._holdings.clear()
284        self._asset_cache.clear()
285        return self

Remove all holdings from the portfolio.

Returns: Self for method chaining.

def set_benchmark(self, index: str) -> Portfolio:
287    def set_benchmark(self, index: str) -> "Portfolio":
288        """
289        Set the benchmark index for beta/alpha calculations.
290
291        Args:
292            index: Index symbol (XU100, XU030, XK030, etc.)
293
294        Returns:
295            Self for method chaining.
296        """
297        self._benchmark = index
298        return self

Set the benchmark index for beta/alpha calculations.

Args: index: Index symbol (XU100, XU030, XK030, etc.)

Returns: Self for method chaining.

def set_target_weights(self, weights: dict[str, float]) -> Portfolio:
302    def set_target_weights(self, weights: dict[str, float]) -> "Portfolio":
303        """Set target allocation weights for rebalancing.
304
305        Args:
306            weights: Dict of symbol -> target weight (0.0 to 1.0 scale).
307                     Must sum to approximately 1.0 (tolerance: 0.01).
308
309        Returns:
310            Self for method chaining.
311
312        Raises:
313            ValueError: If weights don't sum to ~1.0 or contain invalid values.
314
315        Examples:
316            >>> p = Portfolio()
317            >>> p.add("THYAO", shares=100, cost=280)
318            >>> p.add("GARAN", shares=200, cost=50)
319            >>> p.set_target_weights({"THYAO": 0.60, "GARAN": 0.40})
320        """
321        total = sum(weights.values())
322        if abs(total - 1.0) > 0.01:
323            raise ValueError(
324                f"Target weights must sum to ~1.0, got {total:.4f}"
325            )
326        for symbol, weight in weights.items():
327            if weight < 0 or weight > 1:
328                raise ValueError(
329                    f"Weight for {symbol} must be between 0.0 and 1.0, got {weight}"
330                )
331        self._target_weights = dict(weights)
332        return self

Set target allocation weights for rebalancing.

Args: weights: Dict of symbol -> target weight (0.0 to 1.0 scale). Must sum to approximately 1.0 (tolerance: 0.01).

Returns: Self for method chaining.

Raises: ValueError: If weights don't sum to ~1.0 or contain invalid values.

Examples:

p = Portfolio() p.add("THYAO", shares=100, cost=280) p.add("GARAN", shares=200, cost=50) p.set_target_weights({"THYAO": 0.60, "GARAN": 0.40})

target_weights: dict[str, float]
334    @property
335    def target_weights(self) -> dict[str, float]:
336        """Get current target allocation weights."""
337        return dict(self._target_weights)

Get current target allocation weights.

def drift(self) -> pandas.core.frame.DataFrame:
339    def drift(self) -> pd.DataFrame:
340        """Calculate drift between current and target weights.
341
342        Returns:
343            DataFrame with columns:
344            - symbol: Asset symbol
345            - current_weight: Current portfolio weight (0-1 scale)
346            - target_weight: Target allocation weight (0-1 scale)
347            - drift: Absolute drift (current - target)
348            - drift_pct: Drift as percentage points
349
350        Raises:
351            ValueError: If target weights are not set.
352        """
353        if not self._target_weights:
354            raise ValueError(
355                "Target weights not set. Call set_target_weights() first."
356            )
357
358        current_weights = self.weights  # dict[str, float] on 0-1 scale
359        rows = []
360
361        # Include all symbols from both current holdings and targets
362        all_symbols = set(current_weights.keys()) | set(self._target_weights.keys())
363
364        for symbol in sorted(all_symbols):
365            current = current_weights.get(symbol, 0.0)
366            target = self._target_weights.get(symbol, 0.0)
367            drift_val = current - target
368            rows.append({
369                "symbol": symbol,
370                "current_weight": round(current, 4),
371                "target_weight": round(target, 4),
372                "drift": round(drift_val, 4),
373                "drift_pct": round(drift_val * 100, 2),
374            })
375
376        return pd.DataFrame(rows)

Calculate drift between current and target weights.

Returns: DataFrame with columns: - symbol: Asset symbol - current_weight: Current portfolio weight (0-1 scale) - target_weight: Target allocation weight (0-1 scale) - drift: Absolute drift (current - target) - drift_pct: Drift as percentage points

Raises: ValueError: If target weights are not set.

def rebalance_plan(self, threshold: float = 0.0) -> pandas.core.frame.DataFrame:
378    def rebalance_plan(self, threshold: float = 0.0) -> pd.DataFrame:
379        """Calculate trades needed to rebalance portfolio.
380
381        Args:
382            threshold: Minimum drift (0-1 scale) to trigger a trade.
383                      E.g., 0.02 = ignore drifts less than 2%.
384
385        Returns:
386            DataFrame with columns:
387            - symbol: Asset symbol
388            - current_shares: Current number of shares
389            - target_shares: Target number of shares
390            - delta_shares: Shares to buy (+) or sell (-)
391            - delta_value: Approximate trade value in TL
392            - action: "BUY", "SELL", or "HOLD"
393
394        Raises:
395            ValueError: If target weights are not set.
396        """
397        if not self._target_weights:
398            raise ValueError(
399                "Target weights not set. Call set_target_weights() first."
400            )
401
402        total_value = self.value
403        if total_value == 0:
404            return pd.DataFrame(
405                columns=["symbol", "current_shares", "target_shares",
406                         "delta_shares", "delta_value", "action"]
407            )
408
409        rows = []
410        all_symbols = set(self._holdings.keys()) | set(self._target_weights.keys())
411
412        for symbol in sorted(all_symbols):
413            holding = self._holdings.get(symbol)
414            target_weight = self._target_weights.get(symbol, 0.0)
415
416            # Get current price
417            if holding:
418                asset = self._get_or_create_asset(symbol, holding.asset_type)
419            else:
420                # Symbol in targets but not in holdings - detect type
421                detected_type = _detect_asset_type(symbol)
422                asset = self._get_or_create_asset(symbol, detected_type)
423            current_price = self._get_current_price(asset)
424
425            current_shares = holding.shares if holding else 0.0
426            current_value = current_shares * current_price
427            current_weight = current_value / total_value if total_value else 0.0
428
429            # Check threshold
430            if abs(current_weight - target_weight) < threshold:
431                rows.append({
432                    "symbol": symbol,
433                    "current_shares": current_shares,
434                    "target_shares": current_shares,
435                    "delta_shares": 0.0,
436                    "delta_value": 0.0,
437                    "action": "HOLD",
438                })
439                continue
440
441            # Calculate target shares
442            target_value = total_value * target_weight
443            if current_price > 0:
444                target_shares = target_value / current_price
445            else:
446                target_shares = 0.0
447
448            # Round stock shares to integers
449            asset_type = holding.asset_type if holding else _detect_asset_type(symbol)
450            if asset_type == "stock":
451                target_shares = round(target_shares)
452
453            delta_shares = target_shares - current_shares
454            delta_value = delta_shares * current_price
455
456            if delta_shares > 0.001:
457                action = "BUY"
458            elif delta_shares < -0.001:
459                action = "SELL"
460            else:
461                action = "HOLD"
462
463            rows.append({
464                "symbol": symbol,
465                "current_shares": current_shares,
466                "target_shares": round(target_shares, 4),
467                "delta_shares": round(delta_shares, 4),
468                "delta_value": round(delta_value, 2),
469                "action": action,
470            })
471
472        return pd.DataFrame(rows)

Calculate trades needed to rebalance portfolio.

Args: threshold: Minimum drift (0-1 scale) to trigger a trade. E.g., 0.02 = ignore drifts less than 2%.

Returns: DataFrame with columns: - symbol: Asset symbol - current_shares: Current number of shares - target_shares: Target number of shares - delta_shares: Shares to buy (+) or sell (-) - delta_value: Approximate trade value in TL - action: "BUY", "SELL", or "HOLD"

Raises: ValueError: If target weights are not set.

def rebalance( self, threshold: float = 0.0, dry_run: bool = False) -> pandas.core.frame.DataFrame:
474    def rebalance(self, threshold: float = 0.0, dry_run: bool = False) -> pd.DataFrame:
475        """Execute rebalance by updating share counts.
476
477        Updates the portfolio holdings to match target weights.
478        Stock shares are rounded to integers; crypto and fund shares
479        remain fractional.
480
481        Args:
482            threshold: Minimum drift (0-1 scale) to trigger a trade.
483            dry_run: If True, return the plan without executing.
484
485        Returns:
486            DataFrame with the rebalance plan (same as rebalance_plan()).
487
488        Raises:
489            ValueError: If target weights are not set.
490        """
491        plan = self.rebalance_plan(threshold=threshold)
492
493        if dry_run or plan.empty:
494            return plan
495
496        for _, row in plan.iterrows():
497            symbol = row["symbol"]
498            action = row["action"]
499            target_shares = row["target_shares"]
500
501            if action == "HOLD":
502                continue
503
504            if symbol in self._holdings:
505                if target_shares <= 0:
506                    self.remove(symbol)
507                else:
508                    self._holdings[symbol].shares = target_shares
509            elif target_shares > 0:
510                # New holding needed
511                detected_type = _detect_asset_type(symbol)
512                asset = self._get_or_create_asset(symbol, detected_type)
513                price = self._get_current_price(asset)
514                self._holdings[symbol] = Holding(
515                    symbol=symbol,
516                    shares=target_shares,
517                    cost_per_share=price,
518                    asset_type=detected_type,
519                    purchase_date=date.today(),
520                )
521
522        return plan

Execute rebalance by updating share counts.

Updates the portfolio holdings to match target weights. Stock shares are rounded to integers; crypto and fund shares remain fractional.

Args: threshold: Minimum drift (0-1 scale) to trigger a trade. dry_run: If True, return the plan without executing.

Returns: DataFrame with the rebalance plan (same as rebalance_plan()).

Raises: ValueError: If target weights are not set.

holdings: pandas.core.frame.DataFrame
526    @property
527    def holdings(self) -> pd.DataFrame:
528        """
529        Get all holdings as a DataFrame.
530
531        Returns:
532            DataFrame with columns:
533            - symbol: Asset symbol
534            - shares: Number of shares
535            - cost: Cost per share
536            - current_price: Current price
537            - value: Current value (shares * price)
538            - weight: Portfolio weight (%)
539            - pnl: Profit/loss (TL)
540            - pnl_pct: Profit/loss (%)
541            - asset_type: Asset type
542            - purchase_date: Date when asset was purchased
543            - holding_days: Number of days since purchase
544        """
545        if not self._holdings:
546            return pd.DataFrame(
547                columns=[
548                    "symbol", "shares", "cost", "current_price",
549                    "value", "weight", "pnl", "pnl_pct", "asset_type",
550                    "purchase_date", "holding_days"
551                ]
552            )
553
554        rows = []
555        total_value = self.value
556        today = date.today()
557
558        for symbol, holding in self._holdings.items():
559            asset = self._get_or_create_asset(symbol, holding.asset_type)
560            current_price = self._get_current_price(asset)
561            value = holding.shares * current_price
562            cost_basis = (holding.shares * holding.cost_per_share) if holding.cost_per_share else 0
563            pnl = value - cost_basis if cost_basis else 0
564            pnl_pct = (pnl / cost_basis * 100) if cost_basis else 0
565            weight = (value / total_value * 100) if total_value else 0
566
567            # Calculate holding days
568            holding_days = None
569            if holding.purchase_date:
570                holding_days = (today - holding.purchase_date).days
571
572            rows.append({
573                "symbol": symbol,
574                "shares": holding.shares,
575                "cost": holding.cost_per_share,
576                "current_price": current_price,
577                "value": value,
578                "weight": round(weight, 2),
579                "pnl": round(pnl, 2),
580                "pnl_pct": round(pnl_pct, 2),
581                "asset_type": holding.asset_type,
582                "purchase_date": holding.purchase_date,
583                "holding_days": holding_days,
584            })
585
586        return pd.DataFrame(rows)

Get all holdings as a DataFrame.

Returns: DataFrame with columns: - symbol: Asset symbol - shares: Number of shares - cost: Cost per share - current_price: Current price - value: Current value (shares * price) - weight: Portfolio weight (%) - pnl: Profit/loss (TL) - pnl_pct: Profit/loss (%) - asset_type: Asset type - purchase_date: Date when asset was purchased - holding_days: Number of days since purchase

symbols: list[str]
588    @property
589    def symbols(self) -> list[str]:
590        """Get list of symbols in portfolio."""
591        return list(self._holdings.keys())

Get list of symbols in portfolio.

value: float
593    @property
594    def value(self) -> float:
595        """Get total portfolio value in TL."""
596        total = 0.0
597        for symbol, holding in self._holdings.items():
598            asset = self._get_or_create_asset(symbol, holding.asset_type)
599            price = self._get_current_price(asset)
600            total += holding.shares * price
601        return total

Get total portfolio value in TL.

cost: float
603    @property
604    def cost(self) -> float:
605        """Get total portfolio cost basis in TL."""
606        total = 0.0
607        for holding in self._holdings.values():
608            if holding.cost_per_share:
609                total += holding.shares * holding.cost_per_share
610        return total

Get total portfolio cost basis in TL.

pnl: float
612    @property
613    def pnl(self) -> float:
614        """Get total profit/loss in TL."""
615        return self.value - self.cost

Get total profit/loss in TL.

pnl_pct: float
617    @property
618    def pnl_pct(self) -> float:
619        """Get total profit/loss as percentage."""
620        cost = self.cost
621        if cost == 0:
622            return 0.0
623        return (self.pnl / cost) * 100

Get total profit/loss as percentage.

weights: dict[str, float]
625    @property
626    def weights(self) -> dict[str, float]:
627        """Get portfolio weights as dictionary."""
628        total_value = self.value
629        if total_value == 0:
630            return {}
631
632        result = {}
633        for symbol, holding in self._holdings.items():
634            asset = self._get_or_create_asset(symbol, holding.asset_type)
635            price = self._get_current_price(asset)
636            value = holding.shares * price
637            result[symbol] = round(value / total_value, 4)
638        return result

Get portfolio weights as dictionary.

def history(self, period: str = '1y') -> pandas.core.frame.DataFrame:
642    def history(self, period: str = "1y") -> pd.DataFrame:
643        """
644        Get historical portfolio value based on current holdings.
645
646        Note: Uses current share counts - does not track historical trades.
647        When purchase_date is set for a holding, only data from that date
648        onwards is included in the portfolio value calculation.
649
650        Args:
651            period: Period for historical data (1d, 5d, 1mo, 3mo, 6mo, 1y).
652
653        Returns:
654            DataFrame with columns: Value, Daily_Return.
655            Index is Date.
656        """
657        if not self._holdings:
658            return pd.DataFrame(columns=["Value", "Daily_Return"])
659
660        all_prices = {}
661        for symbol, holding in self._holdings.items():
662            asset = self._get_or_create_asset(symbol, holding.asset_type)
663            try:
664                hist = asset.history(period=period)
665                if hist.empty:
666                    continue
667
668                # Filter by purchase_date if set
669                if holding.purchase_date:
670                    # Handle both timezone-aware and timezone-naive indices
671                    if hasattr(hist.index, 'tz') and hist.index.tz is not None:
672                        hist = hist[hist.index.date >= holding.purchase_date]
673                    else:
674                        hist = hist[hist.index >= pd.Timestamp(holding.purchase_date)]
675
676                if hist.empty:
677                    continue
678
679                # Use Close for stocks/index, Price for funds
680                price_col = "Close" if "Close" in hist.columns else "Price"
681                all_prices[symbol] = hist[price_col] * holding.shares
682            except Exception:
683                continue
684
685        if not all_prices:
686            return pd.DataFrame(columns=["Value", "Daily_Return"])
687
688        df = pd.DataFrame(all_prices)
689        df = df.dropna(how="all")
690        df["Value"] = df.sum(axis=1)
691        df["Daily_Return"] = df["Value"].pct_change()
692        return df[["Value", "Daily_Return"]]

Get historical portfolio value based on current holdings.

Note: Uses current share counts - does not track historical trades. When purchase_date is set for a holding, only data from that date onwards is included in the portfolio value calculation.

Args: period: Period for historical data (1d, 5d, 1mo, 3mo, 6mo, 1y).

Returns: DataFrame with columns: Value, Daily_Return. Index is Date.

performance: dict[str, float]
694    @property
695    def performance(self) -> dict[str, float]:
696        """
697        Get portfolio performance summary.
698
699        Returns:
700            Dictionary with:
701            - total_return: Total return (%)
702            - annualized_return: Annualized return (%)
703            - total_value: Current value (TL)
704            - total_cost: Total cost (TL)
705            - total_pnl: Profit/loss (TL)
706        """
707        return {
708            "total_return": self.pnl_pct,
709            "annualized_return": np.nan,  # Calculated in risk_metrics
710            "total_value": self.value,
711            "total_cost": self.cost,
712            "total_pnl": self.pnl,
713        }

Get portfolio performance summary.

Returns: Dictionary with: - total_return: Total return (%) - annualized_return: Annualized return (%) - total_value: Current value (TL) - total_cost: Total cost (TL) - total_pnl: Profit/loss (TL)

def risk_metrics( self, period: str = '1y', risk_free_rate: float | None = None) -> dict[str, typing.Any]:
717    def risk_metrics(
718        self,
719        period: str = "1y",
720        risk_free_rate: float | None = None,
721    ) -> dict[str, Any]:
722        """
723        Calculate comprehensive risk metrics.
724
725        Args:
726            period: Period for calculation (1y, 3mo, 6mo).
727            risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
728                           If None, uses current 10Y bond yield.
729
730        Returns:
731            Dictionary with:
732            - annualized_return: Annualized return (%)
733            - annualized_volatility: Annualized volatility (%)
734            - sharpe_ratio: Risk-adjusted return
735            - sortino_ratio: Downside risk-adjusted return
736            - max_drawdown: Maximum drawdown (%)
737            - beta: Beta vs benchmark
738            - alpha: Alpha vs benchmark (%)
739            - risk_free_rate: Risk-free rate used (%)
740            - trading_days: Number of trading days
741        """
742        df = self.history(period=period)
743
744        if df.empty or len(df) < 20:
745            return {
746                "annualized_return": np.nan,
747                "annualized_volatility": np.nan,
748                "sharpe_ratio": np.nan,
749                "sortino_ratio": np.nan,
750                "max_drawdown": np.nan,
751                "beta": np.nan,
752                "alpha": np.nan,
753                "risk_free_rate": np.nan,
754                "trading_days": 0,
755            }
756
757        daily_returns = df["Daily_Return"].dropna()
758        trading_days = len(daily_returns)
759        annualization = 252
760
761        # Annualized return
762        total_return = (df["Value"].iloc[-1] / df["Value"].iloc[0]) - 1
763        years = trading_days / annualization
764        ann_return = ((1 + total_return) ** (1 / years) - 1) * 100
765
766        # Annualized volatility
767        daily_volatility = daily_returns.std()
768        ann_volatility = daily_volatility * np.sqrt(annualization) * 100
769
770        # Get risk-free rate
771        if risk_free_rate is None:
772            try:
773                from borsapy.bond import risk_free_rate as get_rf_rate
774                rf = get_rf_rate() * 100  # Convert to percentage
775            except Exception:
776                rf = 30.0  # Fallback
777        else:
778            rf = risk_free_rate * 100
779
780        # Sharpe Ratio
781        if ann_volatility > 0:
782            sharpe = (ann_return - rf) / ann_volatility
783        else:
784            sharpe = np.nan
785
786        # Sortino Ratio (downside deviation)
787        negative_returns = daily_returns[daily_returns < 0]
788        if len(negative_returns) > 0:
789            downside_deviation = negative_returns.std() * np.sqrt(annualization) * 100
790            if downside_deviation > 0:
791                sortino = (ann_return - rf) / downside_deviation
792            else:
793                sortino = np.nan
794        else:
795            sortino = np.inf  # No negative returns
796
797        # Maximum Drawdown
798        cumulative = (1 + daily_returns).cumprod()
799        running_max = cumulative.cummax()
800        drawdowns = (cumulative - running_max) / running_max
801        max_drawdown = drawdowns.min() * 100
802
803        # Beta and Alpha (vs benchmark)
804        beta = np.nan
805        alpha = np.nan
806
807        try:
808            bench = Index(self._benchmark)
809            bench_hist = bench.history(period=period)
810            if not bench_hist.empty:
811                bench_returns = bench_hist["Close"].pct_change().dropna()
812
813                # Align dates
814                common_dates = daily_returns.index.intersection(bench_returns.index)
815                if len(common_dates) >= 20:
816                    port_ret = daily_returns.loc[common_dates]
817                    bench_ret = bench_returns.loc[common_dates]
818
819                    # Beta = Cov(Rp, Rm) / Var(Rm)
820                    covariance = port_ret.cov(bench_ret)
821                    variance = bench_ret.var()
822                    if variance > 0:
823                        beta = covariance / variance
824
825                        # Alpha = Rp - Rf - Beta * (Rm - Rf)
826                        bench_total = (bench_hist["Close"].iloc[-1] / bench_hist["Close"].iloc[0]) - 1
827                        bench_ann = ((1 + bench_total) ** (1 / years) - 1) * 100
828                        alpha = ann_return - rf - beta * (bench_ann - rf)
829        except Exception:
830            pass
831
832        return {
833            "annualized_return": round(ann_return, 2),
834            "annualized_volatility": round(ann_volatility, 2),
835            "sharpe_ratio": round(sharpe, 2) if not np.isnan(sharpe) else np.nan,
836            "sortino_ratio": round(sortino, 2) if not np.isnan(sortino) and not np.isinf(sortino) else sortino,
837            "max_drawdown": round(max_drawdown, 2),
838            "beta": round(beta, 2) if not np.isnan(beta) else np.nan,
839            "alpha": round(alpha, 2) if not np.isnan(alpha) else np.nan,
840            "risk_free_rate": round(rf, 2),
841            "trading_days": trading_days,
842        }

Calculate comprehensive risk metrics.

Args: period: Period for calculation (1y, 3mo, 6mo). risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%). If None, uses current 10Y bond yield.

Returns: Dictionary with: - annualized_return: Annualized return (%) - annualized_volatility: Annualized volatility (%) - sharpe_ratio: Risk-adjusted return - sortino_ratio: Downside risk-adjusted return - max_drawdown: Maximum drawdown (%) - beta: Beta vs benchmark - alpha: Alpha vs benchmark (%) - risk_free_rate: Risk-free rate used (%) - trading_days: Number of trading days

def sharpe_ratio(self, period: str = '1y') -> float:
844    def sharpe_ratio(self, period: str = "1y") -> float:
845        """
846        Calculate Sharpe ratio.
847
848        Args:
849            period: Period for calculation.
850
851        Returns:
852            Sharpe ratio.
853        """
854        return self.risk_metrics(period=period).get("sharpe_ratio", np.nan)

Calculate Sharpe ratio.

Args: period: Period for calculation.

Returns: Sharpe ratio.

def sortino_ratio(self, period: str = '1y') -> float:
856    def sortino_ratio(self, period: str = "1y") -> float:
857        """
858        Calculate Sortino ratio.
859
860        Args:
861            period: Period for calculation.
862
863        Returns:
864            Sortino ratio.
865        """
866        return self.risk_metrics(period=period).get("sortino_ratio", np.nan)

Calculate Sortino ratio.

Args: period: Period for calculation.

Returns: Sortino ratio.

def beta(self, benchmark: str | None = None, period: str = '1y') -> float:
868    def beta(self, benchmark: str | None = None, period: str = "1y") -> float:
869        """
870        Calculate beta vs benchmark.
871
872        Args:
873            benchmark: Benchmark index. Uses portfolio default if None.
874            period: Period for calculation.
875
876        Returns:
877            Beta coefficient.
878        """
879        if benchmark:
880            old_bench = self._benchmark
881            self._benchmark = benchmark
882            result = self.risk_metrics(period=period).get("beta", np.nan)
883            self._benchmark = old_bench
884            return result
885        return self.risk_metrics(period=period).get("beta", np.nan)

Calculate beta vs benchmark.

Args: benchmark: Benchmark index. Uses portfolio default if None. period: Period for calculation.

Returns: Beta coefficient.

def correlation_matrix(self, period: str = '1y') -> pandas.core.frame.DataFrame:
887    def correlation_matrix(self, period: str = "1y") -> pd.DataFrame:
888        """
889        Calculate correlation matrix between holdings.
890
891        Args:
892            period: Period for calculation.
893
894        Returns:
895            DataFrame with correlation coefficients.
896        """
897        if len(self._holdings) < 2:
898            return pd.DataFrame()
899
900        returns_dict = {}
901        for symbol, holding in self._holdings.items():
902            try:
903                asset = self._get_or_create_asset(symbol, holding.asset_type)
904                hist = asset.history(period=period)
905                if hist.empty:
906                    continue
907                price_col = "Close" if "Close" in hist.columns else "Price"
908                returns_dict[symbol] = hist[price_col].pct_change()
909            except Exception:
910                continue
911
912        if len(returns_dict) < 2:
913            return pd.DataFrame()
914
915        df = pd.DataFrame(returns_dict).dropna()
916        return df.corr()

Calculate correlation matrix between holdings.

Args: period: Period for calculation.

Returns: DataFrame with correlation coefficients.

def to_dict(self) -> dict[str, typing.Any]:
920    def to_dict(self) -> dict[str, Any]:
921        """
922        Export portfolio to dictionary.
923
924        Returns:
925            Dictionary with portfolio data.
926        """
927        result: dict[str, Any] = {
928            "benchmark": self._benchmark,
929            "holdings": [
930                {
931                    "symbol": h.symbol,
932                    "shares": h.shares,
933                    "cost_per_share": h.cost_per_share,
934                    "asset_type": h.asset_type,
935                    "purchase_date": h.purchase_date.isoformat() if h.purchase_date else None,
936                }
937                for h in self._holdings.values()
938            ],
939        }
940        if self._target_weights:
941            result["target_weights"] = dict(self._target_weights)
942        return result

Export portfolio to dictionary.

Returns: Dictionary with portfolio data.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]) -> Portfolio:
944    @classmethod
945    def from_dict(cls, data: dict[str, Any]) -> "Portfolio":
946        """
947        Create portfolio from dictionary.
948
949        Args:
950            data: Dictionary with portfolio data.
951
952        Returns:
953            Portfolio instance.
954        """
955        portfolio = cls(benchmark=data.get("benchmark", "XU100"))
956        for h in data.get("holdings", []):
957            # Parse purchase_date from ISO string
958            purchase_date = None
959            if h.get("purchase_date"):
960                purchase_date = date.fromisoformat(h["purchase_date"])
961
962            portfolio.add(
963                symbol=h["symbol"],
964                shares=h["shares"],
965                cost=h.get("cost_per_share"),
966                asset_type=h.get("asset_type"),
967                purchase_date=purchase_date,
968            )
969        # Restore target weights if present
970        if "target_weights" in data:
971            portfolio._target_weights = dict(data["target_weights"])
972        return portfolio

Create portfolio from dictionary.

Args: data: Dictionary with portfolio data.

Returns: Portfolio instance.

class Index(borsapy.technical.TechnicalMixin):
 55class Index(TechnicalMixin):
 56    """
 57    A yfinance-like interface for Turkish market indices.
 58
 59    Examples:
 60        >>> import borsapy as bp
 61        >>> xu100 = bp.Index("XU100")
 62        >>> xu100.info
 63        {'symbol': 'XU100', 'name': 'BIST 100', 'last': 9500.5, ...}
 64        >>> xu100.history(period="1mo")
 65                         Open      High       Low     Close      Volume
 66        Date
 67        2024-12-01    9400.00  9550.00  9380.00  9500.50  1234567890
 68        ...
 69
 70        # Available indices
 71        >>> bp.indices()
 72        ['XU100', 'XU050', 'XU030', 'XBANK', ...]
 73    """
 74
 75    def __init__(self, symbol: str):
 76        """
 77        Initialize an Index object.
 78
 79        Args:
 80            symbol: Index symbol (e.g., "XU100", "XU030", "XBANK").
 81        """
 82        self._symbol = symbol.upper()
 83        self._tradingview = get_tradingview_provider()
 84        self._bist_index = get_bist_index_provider()
 85        self._info_cache: dict[str, Any] | None = None
 86        self._components_cache: list[dict[str, Any]] | None = None
 87
 88    @property
 89    def symbol(self) -> str:
 90        """Return the index symbol."""
 91        return self._symbol
 92
 93    @property
 94    def info(self) -> dict[str, Any]:
 95        """
 96        Get current index information.
 97
 98        Returns:
 99            Dictionary with index data:
100            - symbol: Index symbol
101            - name: Index full name
102            - last: Current value
103            - open: Opening value
104            - high: Day high
105            - low: Day low
106            - close: Previous close
107            - change: Value change
108            - change_percent: Percent change
109            - update_time: Last update timestamp
110        """
111        if self._info_cache is None:
112            # Use TradingView API to get quote (same endpoint works for indices)
113            quote = self._tradingview.get_quote(self._symbol)
114            quote["name"] = INDICES.get(self._symbol, self._symbol)
115            quote["type"] = "index"
116            self._info_cache = quote
117        return self._info_cache
118
119    @property
120    def components(self) -> list[dict[str, Any]]:
121        """
122        Get constituent stocks of this index.
123
124        Returns:
125            List of component dicts with 'symbol' and 'name' keys.
126            Empty list if index components are not available.
127
128        Examples:
129            >>> import borsapy as bp
130            >>> xu030 = bp.Index("XU030")
131            >>> xu030.components
132            [{'symbol': 'AKBNK', 'name': 'AKBANK'}, ...]
133            >>> len(xu030.components)
134            30
135        """
136        if self._components_cache is None:
137            self._components_cache = self._bist_index.get_components(self._symbol)
138        return self._components_cache
139
140    @property
141    def component_symbols(self) -> list[str]:
142        """
143        Get just the ticker symbols of constituent stocks.
144
145        Returns:
146            List of stock symbols.
147
148        Examples:
149            >>> import borsapy as bp
150            >>> xu030 = bp.Index("XU030")
151            >>> xu030.component_symbols
152            ['AKBNK', 'AKSA', 'AKSEN', ...]
153        """
154        return [c["symbol"] for c in self.components]
155
156    def history(
157        self,
158        period: str = "1mo",
159        interval: str = "1d",
160        start: datetime | str | None = None,
161        end: datetime | str | None = None,
162    ) -> pd.DataFrame:
163        """
164        Get historical index data.
165
166        Args:
167            period: How much data to fetch. Valid periods:
168                    1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, ytd, max.
169                    Ignored if start is provided.
170            interval: Data interval. Valid intervals:
171                      1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo.
172            start: Start date (string or datetime).
173            end: End date (string or datetime). Defaults to today.
174
175        Returns:
176            DataFrame with columns: Open, High, Low, Close, Volume.
177            Index is the Date.
178
179        Examples:
180            >>> idx = Index("XU100")
181            >>> idx.history(period="1mo")  # Last month
182            >>> idx.history(period="1y")  # Last year
183            >>> idx.history(start="2024-01-01", end="2024-06-30")
184        """
185        # Parse dates
186        start_dt = self._parse_date(start) if start else None
187        end_dt = self._parse_date(end) if end else None
188
189        # Use TradingView provider (same API works for indices)
190        return self._tradingview.get_history(
191            symbol=self._symbol,
192            period=period,
193            interval=interval,
194            start=start_dt,
195            end=end_dt,
196        )
197
198    def _parse_date(self, date: str | datetime) -> datetime:
199        """Parse a date string to datetime."""
200        if isinstance(date, datetime):
201            return date
202        for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"]:
203            try:
204                return datetime.strptime(date, fmt)
205            except ValueError:
206                continue
207        raise ValueError(f"Could not parse date: {date}")
208
209    def _get_ta_symbol_info(self) -> tuple[str, str]:
210        """Get TradingView symbol and screener for TA signals.
211
212        Returns:
213            Tuple of (tv_symbol, screener) for TradingView Scanner API.
214        """
215        return (f"BIST:{self._symbol}", "turkey")
216
217    def scan(
218        self,
219        condition: str,
220        period: str = "3mo",
221        interval: str = "1d",
222    ) -> "pd.DataFrame":
223        """Scan index components for technical conditions.
224
225        Convenience method for scanning all stocks in this index.
226
227        Args:
228            condition: Condition string (e.g., "rsi < 30", "price > sma_50")
229            period: Historical data period for indicator calculation
230            interval: Data interval
231
232        Returns:
233            DataFrame with matching symbols and their data
234
235        Examples:
236            >>> import borsapy as bp
237            >>> xu030 = bp.Index("XU030")
238            >>> xu030.scan("rsi < 30")
239            >>> xu030.scan("price > sma_50 and rsi > 50")
240            >>> xu030.scan("sma_20 crosses_above sma_50")
241        """
242        from borsapy.scanner import scan
243
244        return scan(self.component_symbols, condition, period, interval)
245
246    def __repr__(self) -> str:
247        return f"Index('{self._symbol}')"

A yfinance-like interface for Turkish market indices.

Examples:

import borsapy as bp xu100 = bp.Index("XU100") xu100.info {'symbol': 'XU100', 'name': 'BIST 100', 'last': 9500.5, ...} xu100.history(period="1mo") Open High Low Close Volume Date 2024-12-01 9400.00 9550.00 9380.00 9500.50 1234567890 ...

# Available indices
>>> bp.indices()
['XU100', 'XU050', 'XU030', 'XBANK', ...]
Index(symbol: str)
75    def __init__(self, symbol: str):
76        """
77        Initialize an Index object.
78
79        Args:
80            symbol: Index symbol (e.g., "XU100", "XU030", "XBANK").
81        """
82        self._symbol = symbol.upper()
83        self._tradingview = get_tradingview_provider()
84        self._bist_index = get_bist_index_provider()
85        self._info_cache: dict[str, Any] | None = None
86        self._components_cache: list[dict[str, Any]] | None = None

Initialize an Index object.

Args: symbol: Index symbol (e.g., "XU100", "XU030", "XBANK").

symbol: str
88    @property
89    def symbol(self) -> str:
90        """Return the index symbol."""
91        return self._symbol

Return the index symbol.

info: dict[str, typing.Any]
 93    @property
 94    def info(self) -> dict[str, Any]:
 95        """
 96        Get current index information.
 97
 98        Returns:
 99            Dictionary with index data:
100            - symbol: Index symbol
101            - name: Index full name
102            - last: Current value
103            - open: Opening value
104            - high: Day high
105            - low: Day low
106            - close: Previous close
107            - change: Value change
108            - change_percent: Percent change
109            - update_time: Last update timestamp
110        """
111        if self._info_cache is None:
112            # Use TradingView API to get quote (same endpoint works for indices)
113            quote = self._tradingview.get_quote(self._symbol)
114            quote["name"] = INDICES.get(self._symbol, self._symbol)
115            quote["type"] = "index"
116            self._info_cache = quote
117        return self._info_cache

Get current index information.

Returns: Dictionary with index data: - symbol: Index symbol - name: Index full name - last: Current value - open: Opening value - high: Day high - low: Day low - close: Previous close - change: Value change - change_percent: Percent change - update_time: Last update timestamp

components: list[dict[str, typing.Any]]
119    @property
120    def components(self) -> list[dict[str, Any]]:
121        """
122        Get constituent stocks of this index.
123
124        Returns:
125            List of component dicts with 'symbol' and 'name' keys.
126            Empty list if index components are not available.
127
128        Examples:
129            >>> import borsapy as bp
130            >>> xu030 = bp.Index("XU030")
131            >>> xu030.components
132            [{'symbol': 'AKBNK', 'name': 'AKBANK'}, ...]
133            >>> len(xu030.components)
134            30
135        """
136        if self._components_cache is None:
137            self._components_cache = self._bist_index.get_components(self._symbol)
138        return self._components_cache

Get constituent stocks of this index.

Returns: List of component dicts with 'symbol' and 'name' keys. Empty list if index components are not available.

Examples:

import borsapy as bp xu030 = bp.Index("XU030") xu030.components [{'symbol': 'AKBNK', 'name': 'AKBANK'}, ...] len(xu030.components) 30

component_symbols: list[str]
140    @property
141    def component_symbols(self) -> list[str]:
142        """
143        Get just the ticker symbols of constituent stocks.
144
145        Returns:
146            List of stock symbols.
147
148        Examples:
149            >>> import borsapy as bp
150            >>> xu030 = bp.Index("XU030")
151            >>> xu030.component_symbols
152            ['AKBNK', 'AKSA', 'AKSEN', ...]
153        """
154        return [c["symbol"] for c in self.components]

Get just the ticker symbols of constituent stocks.

Returns: List of stock symbols.

Examples:

import borsapy as bp xu030 = bp.Index("XU030") xu030.component_symbols ['AKBNK', 'AKSA', 'AKSEN', ...]

def history( self, period: str = '1mo', interval: str = '1d', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None) -> pandas.core.frame.DataFrame:
156    def history(
157        self,
158        period: str = "1mo",
159        interval: str = "1d",
160        start: datetime | str | None = None,
161        end: datetime | str | None = None,
162    ) -> pd.DataFrame:
163        """
164        Get historical index data.
165
166        Args:
167            period: How much data to fetch. Valid periods:
168                    1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, ytd, max.
169                    Ignored if start is provided.
170            interval: Data interval. Valid intervals:
171                      1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo.
172            start: Start date (string or datetime).
173            end: End date (string or datetime). Defaults to today.
174
175        Returns:
176            DataFrame with columns: Open, High, Low, Close, Volume.
177            Index is the Date.
178
179        Examples:
180            >>> idx = Index("XU100")
181            >>> idx.history(period="1mo")  # Last month
182            >>> idx.history(period="1y")  # Last year
183            >>> idx.history(start="2024-01-01", end="2024-06-30")
184        """
185        # Parse dates
186        start_dt = self._parse_date(start) if start else None
187        end_dt = self._parse_date(end) if end else None
188
189        # Use TradingView provider (same API works for indices)
190        return self._tradingview.get_history(
191            symbol=self._symbol,
192            period=period,
193            interval=interval,
194            start=start_dt,
195            end=end_dt,
196        )

Get historical index data.

Args: period: How much data to fetch. Valid periods: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, ytd, max. Ignored if start is provided. interval: Data interval. Valid intervals: 1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo. start: Start date (string or datetime). end: End date (string or datetime). Defaults to today.

Returns: DataFrame with columns: Open, High, Low, Close, Volume. Index is the Date.

Examples:

idx = Index("XU100") idx.history(period="1mo") # Last month idx.history(period="1y") # Last year idx.history(start="2024-01-01", end="2024-06-30")

def scan( self, condition: str, period: str = '3mo', interval: str = '1d') -> pandas.core.frame.DataFrame:
217    def scan(
218        self,
219        condition: str,
220        period: str = "3mo",
221        interval: str = "1d",
222    ) -> "pd.DataFrame":
223        """Scan index components for technical conditions.
224
225        Convenience method for scanning all stocks in this index.
226
227        Args:
228            condition: Condition string (e.g., "rsi < 30", "price > sma_50")
229            period: Historical data period for indicator calculation
230            interval: Data interval
231
232        Returns:
233            DataFrame with matching symbols and their data
234
235        Examples:
236            >>> import borsapy as bp
237            >>> xu030 = bp.Index("XU030")
238            >>> xu030.scan("rsi < 30")
239            >>> xu030.scan("price > sma_50 and rsi > 50")
240            >>> xu030.scan("sma_20 crosses_above sma_50")
241        """
242        from borsapy.scanner import scan
243
244        return scan(self.component_symbols, condition, period, interval)

Scan index components for technical conditions.

Convenience method for scanning all stocks in this index.

Args: condition: Condition string (e.g., "rsi < 30", "price > sma_50") period: Historical data period for indicator calculation interval: Data interval

Returns: DataFrame with matching symbols and their data

Examples:

import borsapy as bp xu030 = bp.Index("XU030") xu030.scan("rsi < 30") xu030.scan("price > sma_50 and rsi > 50") xu030.scan("sma_20 crosses_above sma_50")

class Inflation:
 11class Inflation:
 12    """
 13    A yfinance-like interface for Turkish inflation data from TCMB.
 14
 15    Examples:
 16        >>> import borsapy as bp
 17        >>> inf = bp.Inflation()
 18
 19        # Get latest inflation
 20        >>> inf.latest()
 21        {'date': '2024-11-01', 'yearly_inflation': 47.09, 'monthly_inflation': 2.24, ...}
 22
 23        # Get TÜFE history
 24        >>> inf.tufe(limit=12)  # Last 12 months
 25                        YearMonth  YearlyInflation  MonthlyInflation
 26        Date
 27        2024-11-01      11-2024             47.09              2.24
 28        ...
 29
 30        # Calculate inflation
 31        >>> inf.calculate(100000, "2020-01", "2024-01")
 32        {'initial_value': 100000, 'final_value': 342515.0, 'total_change': 242.52, ...}
 33    """
 34
 35    def __init__(self):
 36        """Initialize an Inflation object."""
 37        self._provider = get_tcmb_provider()
 38
 39    def latest(self, inflation_type: str = "tufe") -> dict[str, Any]:
 40        """
 41        Get the latest inflation data.
 42
 43        Args:
 44            inflation_type: 'tufe' (CPI) or 'ufe' (PPI)
 45
 46        Returns:
 47            Dictionary with latest inflation data:
 48            - date: Date string (YYYY-MM-DD)
 49            - year_month: Month-Year string
 50            - yearly_inflation: Year-over-year inflation rate
 51            - monthly_inflation: Month-over-month inflation rate
 52            - type: Inflation type (TUFE or UFE)
 53        """
 54        return self._provider.get_latest(inflation_type)
 55
 56    def tufe(
 57        self,
 58        start: str | None = None,
 59        end: str | None = None,
 60        limit: int | None = None,
 61    ) -> pd.DataFrame:
 62        """
 63        Get TÜFE (Consumer Price Index) data.
 64
 65        Args:
 66            start: Start date in YYYY-MM-DD format
 67            end: End date in YYYY-MM-DD format
 68            limit: Maximum number of records
 69
 70        Returns:
 71            DataFrame with columns: YearMonth, YearlyInflation, MonthlyInflation.
 72            Index is the Date.
 73
 74        Examples:
 75            >>> inf = Inflation()
 76            >>> inf.tufe(limit=6)  # Last 6 months
 77            >>> inf.tufe(start="2023-01-01", end="2023-12-31")  # 2023 data
 78        """
 79        return self._provider.get_data("tufe", start, end, limit)
 80
 81    def ufe(
 82        self,
 83        start: str | None = None,
 84        end: str | None = None,
 85        limit: int | None = None,
 86    ) -> pd.DataFrame:
 87        """
 88        Get ÜFE (Producer Price Index) data.
 89
 90        Args:
 91            start: Start date in YYYY-MM-DD format
 92            end: End date in YYYY-MM-DD format
 93            limit: Maximum number of records
 94
 95        Returns:
 96            DataFrame with columns: YearMonth, YearlyInflation, MonthlyInflation.
 97            Index is the Date.
 98
 99        Examples:
100            >>> inf = Inflation()
101            >>> inf.ufe(limit=6)  # Last 6 months
102            >>> inf.ufe(start="2023-01-01", end="2023-12-31")  # 2023 data
103        """
104        return self._provider.get_data("ufe", start, end, limit)
105
106    def calculate(
107        self,
108        amount: float,
109        start: str,
110        end: str,
111    ) -> dict[str, Any]:
112        """
113        Calculate inflation-adjusted value between two dates.
114
115        Uses TCMB's official inflation calculator API.
116
117        Args:
118            amount: Initial amount in TRY
119            start: Start date in YYYY-MM format (e.g., "2020-01")
120            end: End date in YYYY-MM format (e.g., "2024-01")
121
122        Returns:
123            Dictionary with:
124            - start_date: Start date
125            - end_date: End date
126            - initial_value: Initial amount
127            - final_value: Inflation-adjusted value
128            - total_years: Total years elapsed
129            - total_months: Total months elapsed
130            - total_change: Total percentage change
131            - avg_yearly_inflation: Average yearly inflation rate
132            - start_cpi: CPI at start date
133            - end_cpi: CPI at end date
134
135        Examples:
136            >>> inf = Inflation()
137            >>> result = inf.calculate(100000, "2020-01", "2024-01")
138            >>> print(f"100,000 TL in 2020 = {result['final_value']:,.0f} TL in 2024")
139            100,000 TL in 2020 = 342,515 TL in 2024
140        """
141        start_year, start_month = self._parse_year_month(start)
142        end_year, end_month = self._parse_year_month(end)
143
144        return self._provider.calculate_inflation(
145            start_year=start_year,
146            start_month=start_month,
147            end_year=end_year,
148            end_month=end_month,
149            basket_value=amount,
150        )
151
152    def _parse_year_month(self, date_str: str) -> tuple[int, int]:
153        """Parse YYYY-MM format to (year, month) tuple."""
154        try:
155            parts = date_str.split("-")
156            if len(parts) != 2:
157                raise ValueError(f"Invalid date format: {date_str}. Use YYYY-MM")
158            year = int(parts[0])
159            month = int(parts[1])
160            if not (1 <= month <= 12):
161                raise ValueError(f"Invalid month: {month}")
162            return year, month
163        except Exception as e:
164            raise ValueError(f"Could not parse date '{date_str}': {e}") from e
165
166    def __repr__(self) -> str:
167        return "Inflation()"

A yfinance-like interface for Turkish inflation data from TCMB.

Examples:

import borsapy as bp inf = bp.Inflation()

# Get latest inflation
>>> inf.latest()
{'date': '2024-11-01', 'yearly_inflation': 47.09, 'monthly_inflation': 2.24, ...}

# Get TÜFE history
>>> inf.tufe(limit=12)  # Last 12 months
                YearMonth  YearlyInflation  MonthlyInflation
Date
2024-11-01      11-2024             47.09              2.24
...

# Calculate inflation
>>> inf.calculate(100000, "2020-01", "2024-01")
{'initial_value': 100000, 'final_value': 342515.0, 'total_change': 242.52, ...}
Inflation()
35    def __init__(self):
36        """Initialize an Inflation object."""
37        self._provider = get_tcmb_provider()

Initialize an Inflation object.

def latest(self, inflation_type: str = 'tufe') -> dict[str, typing.Any]:
39    def latest(self, inflation_type: str = "tufe") -> dict[str, Any]:
40        """
41        Get the latest inflation data.
42
43        Args:
44            inflation_type: 'tufe' (CPI) or 'ufe' (PPI)
45
46        Returns:
47            Dictionary with latest inflation data:
48            - date: Date string (YYYY-MM-DD)
49            - year_month: Month-Year string
50            - yearly_inflation: Year-over-year inflation rate
51            - monthly_inflation: Month-over-month inflation rate
52            - type: Inflation type (TUFE or UFE)
53        """
54        return self._provider.get_latest(inflation_type)

Get the latest inflation data.

Args: inflation_type: 'tufe' (CPI) or 'ufe' (PPI)

Returns: Dictionary with latest inflation data: - date: Date string (YYYY-MM-DD) - year_month: Month-Year string - yearly_inflation: Year-over-year inflation rate - monthly_inflation: Month-over-month inflation rate - type: Inflation type (TUFE or UFE)

def tufe( self, start: str | None = None, end: str | None = None, limit: int | None = None) -> pandas.core.frame.DataFrame:
56    def tufe(
57        self,
58        start: str | None = None,
59        end: str | None = None,
60        limit: int | None = None,
61    ) -> pd.DataFrame:
62        """
63        Get TÜFE (Consumer Price Index) data.
64
65        Args:
66            start: Start date in YYYY-MM-DD format
67            end: End date in YYYY-MM-DD format
68            limit: Maximum number of records
69
70        Returns:
71            DataFrame with columns: YearMonth, YearlyInflation, MonthlyInflation.
72            Index is the Date.
73
74        Examples:
75            >>> inf = Inflation()
76            >>> inf.tufe(limit=6)  # Last 6 months
77            >>> inf.tufe(start="2023-01-01", end="2023-12-31")  # 2023 data
78        """
79        return self._provider.get_data("tufe", start, end, limit)

Get TÜFE (Consumer Price Index) data.

Args: start: Start date in YYYY-MM-DD format end: End date in YYYY-MM-DD format limit: Maximum number of records

Returns: DataFrame with columns: YearMonth, YearlyInflation, MonthlyInflation. Index is the Date.

Examples:

inf = Inflation() inf.tufe(limit=6) # Last 6 months inf.tufe(start="2023-01-01", end="2023-12-31") # 2023 data

def ufe( self, start: str | None = None, end: str | None = None, limit: int | None = None) -> pandas.core.frame.DataFrame:
 81    def ufe(
 82        self,
 83        start: str | None = None,
 84        end: str | None = None,
 85        limit: int | None = None,
 86    ) -> pd.DataFrame:
 87        """
 88        Get ÜFE (Producer Price Index) data.
 89
 90        Args:
 91            start: Start date in YYYY-MM-DD format
 92            end: End date in YYYY-MM-DD format
 93            limit: Maximum number of records
 94
 95        Returns:
 96            DataFrame with columns: YearMonth, YearlyInflation, MonthlyInflation.
 97            Index is the Date.
 98
 99        Examples:
100            >>> inf = Inflation()
101            >>> inf.ufe(limit=6)  # Last 6 months
102            >>> inf.ufe(start="2023-01-01", end="2023-12-31")  # 2023 data
103        """
104        return self._provider.get_data("ufe", start, end, limit)

Get ÜFE (Producer Price Index) data.

Args: start: Start date in YYYY-MM-DD format end: End date in YYYY-MM-DD format limit: Maximum number of records

Returns: DataFrame with columns: YearMonth, YearlyInflation, MonthlyInflation. Index is the Date.

Examples:

inf = Inflation() inf.ufe(limit=6) # Last 6 months inf.ufe(start="2023-01-01", end="2023-12-31") # 2023 data

def calculate(self, amount: float, start: str, end: str) -> dict[str, typing.Any]:
106    def calculate(
107        self,
108        amount: float,
109        start: str,
110        end: str,
111    ) -> dict[str, Any]:
112        """
113        Calculate inflation-adjusted value between two dates.
114
115        Uses TCMB's official inflation calculator API.
116
117        Args:
118            amount: Initial amount in TRY
119            start: Start date in YYYY-MM format (e.g., "2020-01")
120            end: End date in YYYY-MM format (e.g., "2024-01")
121
122        Returns:
123            Dictionary with:
124            - start_date: Start date
125            - end_date: End date
126            - initial_value: Initial amount
127            - final_value: Inflation-adjusted value
128            - total_years: Total years elapsed
129            - total_months: Total months elapsed
130            - total_change: Total percentage change
131            - avg_yearly_inflation: Average yearly inflation rate
132            - start_cpi: CPI at start date
133            - end_cpi: CPI at end date
134
135        Examples:
136            >>> inf = Inflation()
137            >>> result = inf.calculate(100000, "2020-01", "2024-01")
138            >>> print(f"100,000 TL in 2020 = {result['final_value']:,.0f} TL in 2024")
139            100,000 TL in 2020 = 342,515 TL in 2024
140        """
141        start_year, start_month = self._parse_year_month(start)
142        end_year, end_month = self._parse_year_month(end)
143
144        return self._provider.calculate_inflation(
145            start_year=start_year,
146            start_month=start_month,
147            end_year=end_year,
148            end_month=end_month,
149            basket_value=amount,
150        )

Calculate inflation-adjusted value between two dates.

Uses TCMB's official inflation calculator API.

Args: amount: Initial amount in TRY start: Start date in YYYY-MM format (e.g., "2020-01") end: End date in YYYY-MM format (e.g., "2024-01")

Returns: Dictionary with: - start_date: Start date - end_date: End date - initial_value: Initial amount - final_value: Inflation-adjusted value - total_years: Total years elapsed - total_months: Total months elapsed - total_change: Total percentage change - avg_yearly_inflation: Average yearly inflation rate - start_cpi: CPI at start date - end_cpi: CPI at end date

Examples:

inf = Inflation() result = inf.calculate(100000, "2020-01", "2024-01") print(f"100,000 TL in 2020 = {result['final_value']:,.0f} TL in 2024") 100,000 TL in 2020 = 342,515 TL in 2024

class VIOP:
 13class VIOP:
 14    """
 15    VİOP (Vadeli İşlem ve Opsiyon Piyasası) data access.
 16
 17    Provides access to Turkish derivatives market data including
 18    futures and options contracts.
 19
 20    Data source: İş Yatırım (HTML scraping)
 21    Note: Data is delayed by ~15 minutes
 22
 23    Examples:
 24        >>> from borsapy import VIOP
 25        >>> viop = VIOP()
 26        >>> viop.futures  # All futures contracts
 27        >>> viop.stock_futures  # Stock futures only
 28        >>> viop.options  # All options contracts
 29    """
 30
 31    def __init__(self) -> None:
 32        """Initialize VİOP data accessor."""
 33        self._provider = get_viop_provider()
 34
 35    @cached_property
 36    def futures(self) -> pd.DataFrame:
 37        """
 38        Get all futures contracts.
 39
 40        Returns:
 41            DataFrame with columns:
 42                - code: Contract code (e.g., F_AKBNK0226)
 43                - contract: Contract name (e.g., AKBNK Åžubat 2026 Vadeli)
 44                - price: Last price
 45                - change: Price change
 46                - volume_tl: Trading volume in TL
 47                - volume_qty: Trading volume in contracts
 48                - category: stock, index, currency, or commodity
 49        """
 50        return self._provider.get_futures("all")
 51
 52    @cached_property
 53    def stock_futures(self) -> pd.DataFrame:
 54        """
 55        Get stock futures contracts (Pay Vadeli İşlem).
 56
 57        Returns:
 58            DataFrame with futures on individual stocks.
 59        """
 60        return self._provider.get_futures("stock")
 61
 62    @cached_property
 63    def index_futures(self) -> pd.DataFrame:
 64        """
 65        Get index futures contracts (Endeks Vadeli İşlem).
 66
 67        Includes XU030, XLBNK, etc.
 68
 69        Returns:
 70            DataFrame with index futures.
 71        """
 72        return self._provider.get_futures("index")
 73
 74    @cached_property
 75    def currency_futures(self) -> pd.DataFrame:
 76        """
 77        Get currency futures contracts (Döviz Vadeli İşlem).
 78
 79        Includes USD/TRY, EUR/TRY, etc.
 80
 81        Returns:
 82            DataFrame with currency futures.
 83        """
 84        return self._provider.get_futures("currency")
 85
 86    @cached_property
 87    def commodity_futures(self) -> pd.DataFrame:
 88        """
 89        Get commodity futures contracts (Kıymetli Madenler).
 90
 91        Includes gold, silver, platinum, palladium.
 92
 93        Returns:
 94            DataFrame with commodity futures.
 95        """
 96        return self._provider.get_futures("commodity")
 97
 98    @cached_property
 99    def options(self) -> pd.DataFrame:
100        """
101        Get all options contracts.
102
103        Returns:
104            DataFrame with columns:
105                - code: Contract code
106                - contract: Contract name
107                - price: Last price
108                - change: Price change
109                - volume_tl: Trading volume in TL
110                - volume_qty: Trading volume in contracts
111                - category: stock or index
112        """
113        return self._provider.get_options("all")
114
115    @cached_property
116    def stock_options(self) -> pd.DataFrame:
117        """
118        Get stock options contracts (Pay Opsiyon).
119
120        Returns:
121            DataFrame with options on individual stocks.
122        """
123        return self._provider.get_options("stock")
124
125    @cached_property
126    def index_options(self) -> pd.DataFrame:
127        """
128        Get index options contracts (Endeks Opsiyon).
129
130        Returns:
131            DataFrame with index options.
132        """
133        return self._provider.get_options("index")
134
135    def get_by_symbol(self, symbol: str) -> pd.DataFrame:
136        """
137        Get all derivatives for a specific underlying symbol.
138
139        Args:
140            symbol: Underlying symbol (e.g., "AKBNK", "THYAO", "XU030")
141
142        Returns:
143            DataFrame with all futures and options for the symbol.
144        """
145        symbol = symbol.upper()
146
147        futures = self._provider.get_futures("all")
148        options = self._provider.get_options("all")
149
150        # Filter out empty DataFrames before concat
151        dfs = [df for df in [futures, options] if not df.empty]
152        if not dfs:
153            return pd.DataFrame(columns=["code", "contract", "price", "change", "volume_tl", "volume_qty", "category"])
154
155        all_data = pd.concat(dfs, ignore_index=True)
156
157        # Filter by symbol in contract name or code
158        mask = (
159            all_data["contract"].str.upper().str.contains(symbol, na=False) |
160            all_data["code"].str.upper().str.contains(symbol, na=False)
161        )
162
163        return all_data[mask].reset_index(drop=True)

VİOP (Vadeli İşlem ve Opsiyon Piyasası) data access.

Provides access to Turkish derivatives market data including futures and options contracts.

Data source: İş Yatırım (HTML scraping) Note: Data is delayed by ~15 minutes

Examples:

from borsapy import VIOP viop = VIOP() viop.futures # All futures contracts viop.stock_futures # Stock futures only viop.options # All options contracts

VIOP()
31    def __init__(self) -> None:
32        """Initialize VİOP data accessor."""
33        self._provider = get_viop_provider()

Initialize VİOP data accessor.

futures: pandas.core.frame.DataFrame
35    @cached_property
36    def futures(self) -> pd.DataFrame:
37        """
38        Get all futures contracts.
39
40        Returns:
41            DataFrame with columns:
42                - code: Contract code (e.g., F_AKBNK0226)
43                - contract: Contract name (e.g., AKBNK Åžubat 2026 Vadeli)
44                - price: Last price
45                - change: Price change
46                - volume_tl: Trading volume in TL
47                - volume_qty: Trading volume in contracts
48                - category: stock, index, currency, or commodity
49        """
50        return self._provider.get_futures("all")

Get all futures contracts.

Returns: DataFrame with columns: - code: Contract code (e.g., F_AKBNK0226) - contract: Contract name (e.g., AKBNK Åžubat 2026 Vadeli) - price: Last price - change: Price change - volume_tl: Trading volume in TL - volume_qty: Trading volume in contracts - category: stock, index, currency, or commodity

stock_futures: pandas.core.frame.DataFrame
52    @cached_property
53    def stock_futures(self) -> pd.DataFrame:
54        """
55        Get stock futures contracts (Pay Vadeli İşlem).
56
57        Returns:
58            DataFrame with futures on individual stocks.
59        """
60        return self._provider.get_futures("stock")

Get stock futures contracts (Pay Vadeli İşlem).

Returns: DataFrame with futures on individual stocks.

index_futures: pandas.core.frame.DataFrame
62    @cached_property
63    def index_futures(self) -> pd.DataFrame:
64        """
65        Get index futures contracts (Endeks Vadeli İşlem).
66
67        Includes XU030, XLBNK, etc.
68
69        Returns:
70            DataFrame with index futures.
71        """
72        return self._provider.get_futures("index")

Get index futures contracts (Endeks Vadeli İşlem).

Includes XU030, XLBNK, etc.

Returns: DataFrame with index futures.

currency_futures: pandas.core.frame.DataFrame
74    @cached_property
75    def currency_futures(self) -> pd.DataFrame:
76        """
77        Get currency futures contracts (Döviz Vadeli İşlem).
78
79        Includes USD/TRY, EUR/TRY, etc.
80
81        Returns:
82            DataFrame with currency futures.
83        """
84        return self._provider.get_futures("currency")

Get currency futures contracts (Döviz Vadeli İşlem).

Includes USD/TRY, EUR/TRY, etc.

Returns: DataFrame with currency futures.

commodity_futures: pandas.core.frame.DataFrame
86    @cached_property
87    def commodity_futures(self) -> pd.DataFrame:
88        """
89        Get commodity futures contracts (Kıymetli Madenler).
90
91        Includes gold, silver, platinum, palladium.
92
93        Returns:
94            DataFrame with commodity futures.
95        """
96        return self._provider.get_futures("commodity")

Get commodity futures contracts (Kıymetli Madenler).

Includes gold, silver, platinum, palladium.

Returns: DataFrame with commodity futures.

options: pandas.core.frame.DataFrame
 98    @cached_property
 99    def options(self) -> pd.DataFrame:
100        """
101        Get all options contracts.
102
103        Returns:
104            DataFrame with columns:
105                - code: Contract code
106                - contract: Contract name
107                - price: Last price
108                - change: Price change
109                - volume_tl: Trading volume in TL
110                - volume_qty: Trading volume in contracts
111                - category: stock or index
112        """
113        return self._provider.get_options("all")

Get all options contracts.

Returns: DataFrame with columns: - code: Contract code - contract: Contract name - price: Last price - change: Price change - volume_tl: Trading volume in TL - volume_qty: Trading volume in contracts - category: stock or index

stock_options: pandas.core.frame.DataFrame
115    @cached_property
116    def stock_options(self) -> pd.DataFrame:
117        """
118        Get stock options contracts (Pay Opsiyon).
119
120        Returns:
121            DataFrame with options on individual stocks.
122        """
123        return self._provider.get_options("stock")

Get stock options contracts (Pay Opsiyon).

Returns: DataFrame with options on individual stocks.

index_options: pandas.core.frame.DataFrame
125    @cached_property
126    def index_options(self) -> pd.DataFrame:
127        """
128        Get index options contracts (Endeks Opsiyon).
129
130        Returns:
131            DataFrame with index options.
132        """
133        return self._provider.get_options("index")

Get index options contracts (Endeks Opsiyon).

Returns: DataFrame with index options.

def get_by_symbol(self, symbol: str) -> pandas.core.frame.DataFrame:
135    def get_by_symbol(self, symbol: str) -> pd.DataFrame:
136        """
137        Get all derivatives for a specific underlying symbol.
138
139        Args:
140            symbol: Underlying symbol (e.g., "AKBNK", "THYAO", "XU030")
141
142        Returns:
143            DataFrame with all futures and options for the symbol.
144        """
145        symbol = symbol.upper()
146
147        futures = self._provider.get_futures("all")
148        options = self._provider.get_options("all")
149
150        # Filter out empty DataFrames before concat
151        dfs = [df for df in [futures, options] if not df.empty]
152        if not dfs:
153            return pd.DataFrame(columns=["code", "contract", "price", "change", "volume_tl", "volume_qty", "category"])
154
155        all_data = pd.concat(dfs, ignore_index=True)
156
157        # Filter by symbol in contract name or code
158        mask = (
159            all_data["contract"].str.upper().str.contains(symbol, na=False) |
160            all_data["code"].str.upper().str.contains(symbol, na=False)
161        )
162
163        return all_data[mask].reset_index(drop=True)

Get all derivatives for a specific underlying symbol.

Args: symbol: Underlying symbol (e.g., "AKBNK", "THYAO", "XU030")

Returns: DataFrame with all futures and options for the symbol.

class Bond:
 11class Bond:
 12    """
 13    A yfinance-like interface for Turkish government bond data.
 14
 15    Data source: doviz.com/tahvil
 16
 17    Examples:
 18        >>> import borsapy as bp
 19        >>> bond = bp.Bond("10Y")
 20        >>> bond.yield_rate  # Current yield (e.g., 28.03)
 21        28.03
 22        >>> bond.yield_decimal  # As decimal (e.g., 0.2803)
 23        0.2803
 24        >>> bond.change_pct  # Daily change percentage
 25        1.5
 26
 27        >>> bp.bonds()  # Get all bond yields
 28                  name maturity   yield  change  change_pct
 29        0  2 Yıllık Tahvil       2Y   26.42    0.40        1.54
 30        1  5 Yıllık Tahvil       5Y   27.15    0.35        1.31
 31        2 10 Yıllık Tahvil      10Y   28.03    0.42        1.52
 32    """
 33
 34    # Valid maturities
 35    MATURITIES = ["2Y", "5Y", "10Y"]
 36
 37    def __init__(self, maturity: str):
 38        """
 39        Initialize a Bond object.
 40
 41        Args:
 42            maturity: Bond maturity (2Y, 5Y, 10Y).
 43        """
 44        self._maturity = maturity.upper()
 45        self._provider = get_tahvil_provider()
 46        self._data_cache: dict[str, Any] | None = None
 47
 48    @property
 49    def maturity(self) -> str:
 50        """Return the bond maturity."""
 51        return self._maturity
 52
 53    @property
 54    def _data(self) -> dict[str, Any]:
 55        """Get bond data (cached)."""
 56        if self._data_cache is None:
 57            self._data_cache = self._provider.get_bond(self._maturity)
 58        return self._data_cache
 59
 60    @property
 61    def name(self) -> str:
 62        """Return the bond name."""
 63        return self._data.get("name", "")
 64
 65    @property
 66    def yield_rate(self) -> float | None:
 67        """
 68        Return the current yield as percentage.
 69
 70        Returns:
 71            Yield rate as percentage (e.g., 28.03 for 28.03%).
 72        """
 73        return self._data.get("yield")
 74
 75    @property
 76    def yield_decimal(self) -> float | None:
 77        """
 78        Return the current yield as decimal.
 79
 80        Returns:
 81            Yield rate as decimal (e.g., 0.2803 for 28.03%).
 82            Useful for financial calculations.
 83        """
 84        return self._data.get("yield_decimal")
 85
 86    @property
 87    def change(self) -> float | None:
 88        """Return the absolute change in yield."""
 89        return self._data.get("change")
 90
 91    @property
 92    def change_pct(self) -> float | None:
 93        """Return the percentage change in yield."""
 94        return self._data.get("change_pct")
 95
 96    @property
 97    def info(self) -> dict[str, Any]:
 98        """
 99        Return all bond information.
100
101        Returns:
102            Dictionary with name, maturity, yield, change, etc.
103        """
104        return self._data.copy()
105
106    def __repr__(self) -> str:
107        return f"Bond('{self._maturity}')"

A yfinance-like interface for Turkish government bond data.

Data source: doviz.com/tahvil

Examples:

import borsapy as bp bond = bp.Bond("10Y") bond.yield_rate # Current yield (e.g., 28.03) 28.03 bond.yield_decimal # As decimal (e.g., 0.2803) 0.2803 bond.change_pct # Daily change percentage 1.5

>>> bp.bonds()  # Get all bond yields
          name maturity   yield  change  change_pct
0  2 Yıllık Tahvil       2Y   26.42    0.40        1.54
1  5 Yıllık Tahvil       5Y   27.15    0.35        1.31
2 10 Yıllık Tahvil      10Y   28.03    0.42        1.52
Bond(maturity: str)
37    def __init__(self, maturity: str):
38        """
39        Initialize a Bond object.
40
41        Args:
42            maturity: Bond maturity (2Y, 5Y, 10Y).
43        """
44        self._maturity = maturity.upper()
45        self._provider = get_tahvil_provider()
46        self._data_cache: dict[str, Any] | None = None

Initialize a Bond object.

Args: maturity: Bond maturity (2Y, 5Y, 10Y).

MATURITIES = ['2Y', '5Y', '10Y']
maturity: str
48    @property
49    def maturity(self) -> str:
50        """Return the bond maturity."""
51        return self._maturity

Return the bond maturity.

name: str
60    @property
61    def name(self) -> str:
62        """Return the bond name."""
63        return self._data.get("name", "")

Return the bond name.

yield_rate: float | None
65    @property
66    def yield_rate(self) -> float | None:
67        """
68        Return the current yield as percentage.
69
70        Returns:
71            Yield rate as percentage (e.g., 28.03 for 28.03%).
72        """
73        return self._data.get("yield")

Return the current yield as percentage.

Returns: Yield rate as percentage (e.g., 28.03 for 28.03%).

yield_decimal: float | None
75    @property
76    def yield_decimal(self) -> float | None:
77        """
78        Return the current yield as decimal.
79
80        Returns:
81            Yield rate as decimal (e.g., 0.2803 for 28.03%).
82            Useful for financial calculations.
83        """
84        return self._data.get("yield_decimal")

Return the current yield as decimal.

Returns: Yield rate as decimal (e.g., 0.2803 for 28.03%). Useful for financial calculations.

change: float | None
86    @property
87    def change(self) -> float | None:
88        """Return the absolute change in yield."""
89        return self._data.get("change")

Return the absolute change in yield.

change_pct: float | None
91    @property
92    def change_pct(self) -> float | None:
93        """Return the percentage change in yield."""
94        return self._data.get("change_pct")

Return the percentage change in yield.

info: dict[str, typing.Any]
 96    @property
 97    def info(self) -> dict[str, Any]:
 98        """
 99        Return all bond information.
100
101        Returns:
102            Dictionary with name, maturity, yield, change, etc.
103        """
104        return self._data.copy()

Return all bond information.

Returns: Dictionary with name, maturity, yield, change, etc.

class Eurobond:
 61class Eurobond:
 62    """Single Turkish sovereign Eurobond interface.
 63
 64    Provides access to bond data including prices, yields,
 65    maturity, and other characteristics.
 66
 67    Attributes:
 68        isin: ISIN code of the bond.
 69        maturity: Maturity date.
 70        days_to_maturity: Days until maturity.
 71        currency: Bond currency (USD or EUR).
 72        bid_price: Bid price (buying price).
 73        bid_yield: Bid yield (buying yield).
 74        ask_price: Ask price (selling price).
 75        ask_yield: Ask yield (selling yield).
 76        info: All bond data as dictionary.
 77
 78    Examples:
 79        >>> bond = Eurobond("US900123DG28")
 80        >>> bond.bid_yield
 81        6.55
 82        >>> bond.currency
 83        'USD'
 84    """
 85
 86    def __init__(self, isin: str):
 87        """Initialize Eurobond by ISIN.
 88
 89        Args:
 90            isin: ISIN code (e.g., "US900123DG28").
 91
 92        Raises:
 93            DataNotAvailableError: If bond not found.
 94        """
 95        self._isin = isin.upper()
 96        self._provider = get_eurobond_provider()
 97        self._data_cache: dict | None = None
 98
 99    @property
100    def _data(self) -> dict:
101        """Lazy-loaded bond data."""
102        if self._data_cache is None:
103            self._data_cache = self._provider.get_eurobond(self._isin)
104            if self._data_cache is None:
105                raise DataNotAvailableError(f"Eurobond not found: {self._isin}")
106        return self._data_cache
107
108    @property
109    def isin(self) -> str:
110        """ISIN code of the bond."""
111        return self._data["isin"]
112
113    @property
114    def maturity(self) -> datetime | None:
115        """Maturity date of the bond."""
116        return self._data.get("maturity")
117
118    @property
119    def days_to_maturity(self) -> int:
120        """Number of days until maturity."""
121        return self._data.get("days_to_maturity", 0)
122
123    @property
124    def currency(self) -> str:
125        """Bond currency (USD or EUR)."""
126        return self._data.get("currency", "")
127
128    @property
129    def bid_price(self) -> float | None:
130        """Bid price (buying price)."""
131        return self._data.get("bid_price")
132
133    @property
134    def bid_yield(self) -> float | None:
135        """Bid yield (buying yield) as percentage."""
136        return self._data.get("bid_yield")
137
138    @property
139    def ask_price(self) -> float | None:
140        """Ask price (selling price)."""
141        return self._data.get("ask_price")
142
143    @property
144    def ask_yield(self) -> float | None:
145        """Ask yield (selling yield) as percentage."""
146        return self._data.get("ask_yield")
147
148    @property
149    def info(self) -> dict:
150        """All bond data as dictionary.
151
152        Returns:
153            Dict with all bond attributes.
154        """
155        return self._data.copy()
156
157    def history(
158        self,
159        period: str | None = None,
160        start: str | datetime | date_type | None = None,
161        end: str | datetime | date_type | None = None,
162        skip_weekends: bool = True,
163    ) -> pd.DataFrame:
164        """Fetch daily historical bid/ask prices and yields.
165
166        Args:
167            period: Lookback window ending today. One of 1mo, 3mo, 6mo, 1y,
168                2y, 3y, 5y, 10y, ytd, max. Ignored if ``start`` is given.
169            start: Start date (str "YYYY-MM-DD", datetime, or date).
170            end: End date, defaults to today.
171            skip_weekends: Skip Sat/Sun (API returns zeros on weekends).
172
173        Returns:
174            DataFrame indexed by Date with columns: bid_price, bid_yield,
175            ask_price, ask_yield, days_to_maturity. Holidays and suspended
176            trading days (bid_price == 0) are dropped.
177
178        Examples:
179            >>> bond = bp.Eurobond("US900123DG28")
180            >>> bond.history(period="1y")
181            >>> bond.history(start="2021-08-16", end="2026-03-11")
182
183        Note:
184            Long ranges perform one HTTP request per business day against the
185            Ziraat Bank API — expect ~30 seconds per year of data on a cold
186            cache. Subsequent calls hit the per-date cache.
187        """
188        today = datetime.now().date()
189
190        # Resolve end
191        end_d = _parse_date_arg(end) if end else today
192
193        # Resolve start
194        if start:
195            start_d = _parse_date_arg(start)
196        elif period:
197            if period == "ytd":
198                start_d = date_type(today.year, 1, 1)
199            elif period in _PERIOD_DAYS:
200                start_d = end_d - timedelta(days=_PERIOD_DAYS[period])
201            else:
202                raise ValueError(
203                    f"Unknown period {period!r}. Use start= or one of: "
204                    f"{', '.join(sorted(_PERIOD_DAYS))}"
205                )
206        else:
207            # Default to 1 month
208            start_d = end_d - timedelta(days=30)
209
210        rows = self._provider.get_history(
211            self._isin, start_d, end_d, skip_weekends=skip_weekends
212        )
213
214        columns = [
215            "bid_price",
216            "bid_yield",
217            "ask_price",
218            "ask_yield",
219            "days_to_maturity",
220        ]
221        if not rows:
222            return pd.DataFrame(columns=columns, index=pd.DatetimeIndex([], name="Date"))
223
224        df = pd.DataFrame(rows)
225        df["Date"] = pd.to_datetime(df["date"])
226        df = df.drop(columns=["date"]).set_index("Date")
227        return df[columns]
228
229    def __repr__(self) -> str:
230        """String representation."""
231        try:
232            maturity_year = self.maturity.year if self.maturity else "?"
233            return f"Eurobond({self._isin}, {self.currency}, {maturity_year}, yield={self.bid_yield}%)"
234        except DataNotAvailableError:
235            return f"Eurobond({self._isin})"

Single Turkish sovereign Eurobond interface.

Provides access to bond data including prices, yields, maturity, and other characteristics.

Attributes: isin: ISIN code of the bond. maturity: Maturity date. days_to_maturity: Days until maturity. currency: Bond currency (USD or EUR). bid_price: Bid price (buying price). bid_yield: Bid yield (buying yield). ask_price: Ask price (selling price). ask_yield: Ask yield (selling yield). info: All bond data as dictionary.

Examples:

bond = Eurobond("US900123DG28") bond.bid_yield 6.55 bond.currency 'USD'

Eurobond(isin: str)
86    def __init__(self, isin: str):
87        """Initialize Eurobond by ISIN.
88
89        Args:
90            isin: ISIN code (e.g., "US900123DG28").
91
92        Raises:
93            DataNotAvailableError: If bond not found.
94        """
95        self._isin = isin.upper()
96        self._provider = get_eurobond_provider()
97        self._data_cache: dict | None = None

Initialize Eurobond by ISIN.

Args: isin: ISIN code (e.g., "US900123DG28").

Raises: DataNotAvailableError: If bond not found.

isin: str
108    @property
109    def isin(self) -> str:
110        """ISIN code of the bond."""
111        return self._data["isin"]

ISIN code of the bond.

maturity: datetime.datetime | None
113    @property
114    def maturity(self) -> datetime | None:
115        """Maturity date of the bond."""
116        return self._data.get("maturity")

Maturity date of the bond.

days_to_maturity: int
118    @property
119    def days_to_maturity(self) -> int:
120        """Number of days until maturity."""
121        return self._data.get("days_to_maturity", 0)

Number of days until maturity.

currency: str
123    @property
124    def currency(self) -> str:
125        """Bond currency (USD or EUR)."""
126        return self._data.get("currency", "")

Bond currency (USD or EUR).

bid_price: float | None
128    @property
129    def bid_price(self) -> float | None:
130        """Bid price (buying price)."""
131        return self._data.get("bid_price")

Bid price (buying price).

bid_yield: float | None
133    @property
134    def bid_yield(self) -> float | None:
135        """Bid yield (buying yield) as percentage."""
136        return self._data.get("bid_yield")

Bid yield (buying yield) as percentage.

ask_price: float | None
138    @property
139    def ask_price(self) -> float | None:
140        """Ask price (selling price)."""
141        return self._data.get("ask_price")

Ask price (selling price).

ask_yield: float | None
143    @property
144    def ask_yield(self) -> float | None:
145        """Ask yield (selling yield) as percentage."""
146        return self._data.get("ask_yield")

Ask yield (selling yield) as percentage.

info: dict
148    @property
149    def info(self) -> dict:
150        """All bond data as dictionary.
151
152        Returns:
153            Dict with all bond attributes.
154        """
155        return self._data.copy()

All bond data as dictionary.

Returns: Dict with all bond attributes.

def history( self, period: str | None = None, start: str | datetime.datetime | datetime.date | None = None, end: str | datetime.datetime | datetime.date | None = None, skip_weekends: bool = True) -> pandas.core.frame.DataFrame:
157    def history(
158        self,
159        period: str | None = None,
160        start: str | datetime | date_type | None = None,
161        end: str | datetime | date_type | None = None,
162        skip_weekends: bool = True,
163    ) -> pd.DataFrame:
164        """Fetch daily historical bid/ask prices and yields.
165
166        Args:
167            period: Lookback window ending today. One of 1mo, 3mo, 6mo, 1y,
168                2y, 3y, 5y, 10y, ytd, max. Ignored if ``start`` is given.
169            start: Start date (str "YYYY-MM-DD", datetime, or date).
170            end: End date, defaults to today.
171            skip_weekends: Skip Sat/Sun (API returns zeros on weekends).
172
173        Returns:
174            DataFrame indexed by Date with columns: bid_price, bid_yield,
175            ask_price, ask_yield, days_to_maturity. Holidays and suspended
176            trading days (bid_price == 0) are dropped.
177
178        Examples:
179            >>> bond = bp.Eurobond("US900123DG28")
180            >>> bond.history(period="1y")
181            >>> bond.history(start="2021-08-16", end="2026-03-11")
182
183        Note:
184            Long ranges perform one HTTP request per business day against the
185            Ziraat Bank API — expect ~30 seconds per year of data on a cold
186            cache. Subsequent calls hit the per-date cache.
187        """
188        today = datetime.now().date()
189
190        # Resolve end
191        end_d = _parse_date_arg(end) if end else today
192
193        # Resolve start
194        if start:
195            start_d = _parse_date_arg(start)
196        elif period:
197            if period == "ytd":
198                start_d = date_type(today.year, 1, 1)
199            elif period in _PERIOD_DAYS:
200                start_d = end_d - timedelta(days=_PERIOD_DAYS[period])
201            else:
202                raise ValueError(
203                    f"Unknown period {period!r}. Use start= or one of: "
204                    f"{', '.join(sorted(_PERIOD_DAYS))}"
205                )
206        else:
207            # Default to 1 month
208            start_d = end_d - timedelta(days=30)
209
210        rows = self._provider.get_history(
211            self._isin, start_d, end_d, skip_weekends=skip_weekends
212        )
213
214        columns = [
215            "bid_price",
216            "bid_yield",
217            "ask_price",
218            "ask_yield",
219            "days_to_maturity",
220        ]
221        if not rows:
222            return pd.DataFrame(columns=columns, index=pd.DatetimeIndex([], name="Date"))
223
224        df = pd.DataFrame(rows)
225        df["Date"] = pd.to_datetime(df["date"])
226        df = df.drop(columns=["date"]).set_index("Date")
227        return df[columns]

Fetch daily historical bid/ask prices and yields.

Args: period: Lookback window ending today. One of 1mo, 3mo, 6mo, 1y, 2y, 3y, 5y, 10y, ytd, max. Ignored if start is given. start: Start date (str "YYYY-MM-DD", datetime, or date). end: End date, defaults to today. skip_weekends: Skip Sat/Sun (API returns zeros on weekends).

Returns: DataFrame indexed by Date with columns: bid_price, bid_yield, ask_price, ask_yield, days_to_maturity. Holidays and suspended trading days (bid_price == 0) are dropped.

Examples:

bond = bp.Eurobond("US900123DG28") bond.history(period="1y") bond.history(start="2021-08-16", end="2026-03-11")

Note: Long ranges perform one HTTP request per business day against the Ziraat Bank API — expect ~30 seconds per year of data on a cold cache. Subsequent calls hit the per-date cache.

class TCMB:
 34class TCMB:
 35    """TCMB interest rates interface.
 36
 37    Provides access to Turkish Central Bank policy rates including
 38    the 1-week repo rate (main policy rate), overnight corridor,
 39    and late liquidity window rates.
 40
 41    Attributes:
 42        policy_rate: Current 1-week repo rate (policy rate).
 43        overnight: Overnight corridor rates (borrowing/lending).
 44        late_liquidity: Late liquidity window rates (borrowing/lending).
 45        rates: DataFrame with all current rates.
 46
 47    Examples:
 48        >>> tcmb = TCMB()
 49        >>> tcmb.policy_rate
 50        38.0
 51        >>> tcmb.overnight
 52        {'borrowing': 36.5, 'lending': 41.0}
 53    """
 54
 55    def __init__(self):
 56        """Initialize TCMB interface."""
 57        self._provider = get_tcmb_rates_provider()
 58
 59    @property
 60    def policy_rate(self) -> float | None:
 61        """Get current 1-week repo rate (policy rate).
 62
 63        This is the main policy interest rate set by TCMB.
 64
 65        Returns:
 66            Policy rate as percentage (e.g., 38.0 for 38%).
 67        """
 68        data = self._provider.get_policy_rate()
 69        return data.get("lending")
 70
 71    @property
 72    def overnight(self) -> dict:
 73        """Get overnight (O/N) corridor rates.
 74
 75        The overnight corridor defines the band within which
 76        short-term interest rates can fluctuate.
 77
 78        Returns:
 79            Dict with 'borrowing' and 'lending' rates.
 80
 81        Example:
 82            {'borrowing': 36.5, 'lending': 41.0}
 83        """
 84        data = self._provider.get_overnight_rates()
 85        return {
 86            "borrowing": data.get("borrowing"),
 87            "lending": data.get("lending"),
 88        }
 89
 90    @property
 91    def late_liquidity(self) -> dict:
 92        """Get late liquidity window (LON) rates.
 93
 94        The late liquidity window is a facility for banks
 95        to borrow/lend at the end of the day.
 96
 97        Returns:
 98            Dict with 'borrowing' and 'lending' rates.
 99
100        Example:
101            {'borrowing': 0.0, 'lending': 44.0}
102        """
103        data = self._provider.get_late_liquidity_rates()
104        return {
105            "borrowing": data.get("borrowing"),
106            "lending": data.get("lending"),
107        }
108
109    @property
110    def rates(self) -> pd.DataFrame:
111        """Get all current rates as DataFrame.
112
113        Returns:
114            DataFrame with columns: type, borrowing, lending.
115
116        Example:
117            >>> tcmb.rates
118                       type  borrowing  lending
119            0        policy       None     38.0
120            1     overnight       36.5     41.0
121            2  late_liquidity      0.0     44.0
122        """
123        data = self._provider.get_all_rates()
124        df = pd.DataFrame(data)
125        if "rate_type" in df.columns:
126            df = df.rename(columns={"rate_type": "type"})
127        return df[["type", "borrowing", "lending"]]
128
129    def history(
130        self,
131        rate_type: str = "policy",
132        period: str | None = None,
133    ) -> pd.DataFrame:
134        """Get historical rates for given type.
135
136        Args:
137            rate_type: One of "policy", "overnight", "late_liquidity".
138            period: Optional period filter (e.g., "1y", "5y", "max").
139                   If None, returns all available data.
140
141        Returns:
142            DataFrame with date index and borrowing/lending columns.
143
144        Example:
145            >>> tcmb.history("policy", period="1y")
146                        borrowing  lending
147            date
148            2024-01-25       None     45.0
149            2024-02-22       None     45.0
150            ...
151        """
152        data = self._provider.get_rate_history(rate_type)
153
154        if not data:
155            return pd.DataFrame(columns=["date", "borrowing", "lending"])
156
157        df = pd.DataFrame(data)
158        df["date"] = pd.to_datetime(df["date"])
159        df = df.set_index("date").sort_index()
160
161        # Apply period filter if specified
162        if period:
163            end_date = datetime.now()
164            period_map = {
165                "1w": timedelta(days=7),
166                "1mo": timedelta(days=30),
167                "3mo": timedelta(days=90),
168                "6mo": timedelta(days=180),
169                "1y": timedelta(days=365),
170                "2y": timedelta(days=730),
171                "5y": timedelta(days=1825),
172                "10y": timedelta(days=3650),
173            }
174
175            if period.lower() in period_map:
176                start_date = end_date - period_map[period.lower()]
177                df = df[df.index >= start_date]
178            # "max" or unknown period returns all data
179
180        return df
181
182    def __repr__(self) -> str:
183        """String representation."""
184        rate = self.policy_rate
185        if rate is not None:
186            return f"TCMB(policy_rate={rate}%)"
187        return "TCMB()"

TCMB interest rates interface.

Provides access to Turkish Central Bank policy rates including the 1-week repo rate (main policy rate), overnight corridor, and late liquidity window rates.

Attributes: policy_rate: Current 1-week repo rate (policy rate). overnight: Overnight corridor rates (borrowing/lending). late_liquidity: Late liquidity window rates (borrowing/lending). rates: DataFrame with all current rates.

Examples:

tcmb = TCMB() tcmb.policy_rate 38.0 tcmb.overnight {'borrowing': 36.5, 'lending': 41.0}

TCMB()
55    def __init__(self):
56        """Initialize TCMB interface."""
57        self._provider = get_tcmb_rates_provider()

Initialize TCMB interface.

policy_rate: float | None
59    @property
60    def policy_rate(self) -> float | None:
61        """Get current 1-week repo rate (policy rate).
62
63        This is the main policy interest rate set by TCMB.
64
65        Returns:
66            Policy rate as percentage (e.g., 38.0 for 38%).
67        """
68        data = self._provider.get_policy_rate()
69        return data.get("lending")

Get current 1-week repo rate (policy rate).

This is the main policy interest rate set by TCMB.

Returns: Policy rate as percentage (e.g., 38.0 for 38%).

overnight: dict
71    @property
72    def overnight(self) -> dict:
73        """Get overnight (O/N) corridor rates.
74
75        The overnight corridor defines the band within which
76        short-term interest rates can fluctuate.
77
78        Returns:
79            Dict with 'borrowing' and 'lending' rates.
80
81        Example:
82            {'borrowing': 36.5, 'lending': 41.0}
83        """
84        data = self._provider.get_overnight_rates()
85        return {
86            "borrowing": data.get("borrowing"),
87            "lending": data.get("lending"),
88        }

Get overnight (O/N) corridor rates.

The overnight corridor defines the band within which short-term interest rates can fluctuate.

Returns: Dict with 'borrowing' and 'lending' rates.

Example: {'borrowing': 36.5, 'lending': 41.0}

late_liquidity: dict
 90    @property
 91    def late_liquidity(self) -> dict:
 92        """Get late liquidity window (LON) rates.
 93
 94        The late liquidity window is a facility for banks
 95        to borrow/lend at the end of the day.
 96
 97        Returns:
 98            Dict with 'borrowing' and 'lending' rates.
 99
100        Example:
101            {'borrowing': 0.0, 'lending': 44.0}
102        """
103        data = self._provider.get_late_liquidity_rates()
104        return {
105            "borrowing": data.get("borrowing"),
106            "lending": data.get("lending"),
107        }

Get late liquidity window (LON) rates.

The late liquidity window is a facility for banks to borrow/lend at the end of the day.

Returns: Dict with 'borrowing' and 'lending' rates.

Example: {'borrowing': 0.0, 'lending': 44.0}

rates: pandas.core.frame.DataFrame
109    @property
110    def rates(self) -> pd.DataFrame:
111        """Get all current rates as DataFrame.
112
113        Returns:
114            DataFrame with columns: type, borrowing, lending.
115
116        Example:
117            >>> tcmb.rates
118                       type  borrowing  lending
119            0        policy       None     38.0
120            1     overnight       36.5     41.0
121            2  late_liquidity      0.0     44.0
122        """
123        data = self._provider.get_all_rates()
124        df = pd.DataFrame(data)
125        if "rate_type" in df.columns:
126            df = df.rename(columns={"rate_type": "type"})
127        return df[["type", "borrowing", "lending"]]

Get all current rates as DataFrame.

Returns: DataFrame with columns: type, borrowing, lending.

Example:

tcmb.rates type borrowing lending 0 policy None 38.0 1 overnight 36.5 41.0 2 late_liquidity 0.0 44.0

def history( self, rate_type: str = 'policy', period: str | None = None) -> pandas.core.frame.DataFrame:
129    def history(
130        self,
131        rate_type: str = "policy",
132        period: str | None = None,
133    ) -> pd.DataFrame:
134        """Get historical rates for given type.
135
136        Args:
137            rate_type: One of "policy", "overnight", "late_liquidity".
138            period: Optional period filter (e.g., "1y", "5y", "max").
139                   If None, returns all available data.
140
141        Returns:
142            DataFrame with date index and borrowing/lending columns.
143
144        Example:
145            >>> tcmb.history("policy", period="1y")
146                        borrowing  lending
147            date
148            2024-01-25       None     45.0
149            2024-02-22       None     45.0
150            ...
151        """
152        data = self._provider.get_rate_history(rate_type)
153
154        if not data:
155            return pd.DataFrame(columns=["date", "borrowing", "lending"])
156
157        df = pd.DataFrame(data)
158        df["date"] = pd.to_datetime(df["date"])
159        df = df.set_index("date").sort_index()
160
161        # Apply period filter if specified
162        if period:
163            end_date = datetime.now()
164            period_map = {
165                "1w": timedelta(days=7),
166                "1mo": timedelta(days=30),
167                "3mo": timedelta(days=90),
168                "6mo": timedelta(days=180),
169                "1y": timedelta(days=365),
170                "2y": timedelta(days=730),
171                "5y": timedelta(days=1825),
172                "10y": timedelta(days=3650),
173            }
174
175            if period.lower() in period_map:
176                start_date = end_date - period_map[period.lower()]
177                df = df[df.index >= start_date]
178            # "max" or unknown period returns all data
179
180        return df

Get historical rates for given type.

Args: rate_type: One of "policy", "overnight", "late_liquidity". period: Optional period filter (e.g., "1y", "5y", "max"). If None, returns all available data.

Returns: DataFrame with date index and borrowing/lending columns.

Example:

tcmb.history("policy", period="1y") borrowing lending date 2024-01-25 None 45.0 2024-02-22 None 45.0 ...

class EVDS:
338class EVDS:
339    """Top-level EVDS interface.
340
341    Combines catalogue navigation, search and dashboards. For individual
342    series use :meth:`series` or the module-level :func:`evds_series`
343    shortcut.
344    """
345
346    def __init__(self) -> None:
347        self._provider = get_evds_provider()
348
349    # ----- Catalogue ----------------------------------------------------------
350
351    @property
352    def categories(self) -> pd.DataFrame:
353        """All EVDS top-level categories (one row per category)."""
354        cats = self._provider.get_categories()
355        rows = [
356            {
357                "CATEGORY_ID": c.get("CATEGORY_ID"),
358                "TOPIC_TITLE_TR": c.get("TOPIC_TITLE_TR"),
359                "TOPIC_TITLE_EN": c.get("TOPIC_TITLE_ENG"),
360                "PARENT_CATEGORY_ID": c.get("UST_CATEGORY_ID"),
361                "LEVEL": c.get("SEVIYE"),
362                "DATAGROUP_COUNT": len(c.get("DATAGROUPS", []) or []),
363            }
364            for c in cats
365        ]
366        return pd.DataFrame(rows)
367
368    def datagroups(self, category_id: int | None = None) -> pd.DataFrame:
369        """List datagroups, optionally filtered by category.
370
371        Includes the full metadata set returned by the EVDS catalogue:
372        unit, source, last update, methodology link, revision policy link,
373        application change log link, and free-form notes (where available).
374        """
375        cats = self._provider.get_categories()
376        rows = []
377        for c in cats:
378            if category_id is not None and c.get("CATEGORY_ID") != category_id:
379                continue
380            for dg in c.get("DATAGROUPS", []) or []:
381                rows.append({
382                    "DATAGROUP_CODE": dg.get("DATAGROUP_CODE"),
383                    "DATAGROUP_TYPE": dg.get("DATAGROUP_TYPE"),
384                    "DATAGROUP_TYPE_EN": dg.get("DATAGROUP_TYPE_ENG"),
385                    "CATEGORY_ID": c.get("CATEGORY_ID"),
386                    "CATEGORY_TR": c.get("TOPIC_TITLE_TR"),
387                    "FREQUENCY": dg.get("FREQUENCY"),
388                    "FREQUENCY_STR": dg.get("FREQUENCY_STR"),
389                    "UNIT_TR": dg.get("BIRIMI"),
390                    "UNIT_EN": dg.get("BIRIMI_EN"),
391                    "DATA_SOURCE": dg.get("DATASOURCE"),
392                    "DATA_SOURCE_EN": dg.get("DATASOURCE_ENG"),
393                    "LAST_UPDATED": dg.get("LAST_UPDATED"),
394                    "METADATA_LINK": dg.get("METADATA_LINK"),
395                    "METADATA_LINK_EN": dg.get("METADATA_LINK_ENG"),
396                    "REV_POL_LINK": dg.get("REV_POL_LINK"),
397                    "REV_POL_LINK_EN": dg.get("REV_POL_LINK_ENG"),
398                    "APP_CHA_LINK": dg.get("APP_CHA_LINK"),
399                    "APP_CHA_LINK_EN": dg.get("APP_CHA_LINK_ENG"),
400                    "NOTE": dg.get("NOTE"),
401                    "NOTE_EN": dg.get("NOTE_ENG"),
402                })
403        return pd.DataFrame(rows)
404
405    def series_in_group(self, datagroup_code: str) -> pd.DataFrame:
406        """List all series that belong to a datagroup."""
407        rows = self._provider.get_series_list(datagroup_code)
408        if not rows:
409            return pd.DataFrame()
410        # Surface dot-form codes for friendlier UX.
411        for r in rows:
412            if r.get("SERIE_CODE"):
413                r["SERIE_CODE"] = denormalize_code(r["SERIE_CODE"])
414        return pd.DataFrame(rows)
415
416    def search(
417        self,
418        term: str,
419        lang: str = "tr",
420        scope: str = "all",
421    ) -> pd.DataFrame:
422        """Full-text search across categories, datagroups and series.
423
424        Args:
425            term: Substring to look for (case-insensitive).
426            lang: ``"tr"`` (default) — searches Turkish + English titles.
427                ``"en"`` — restrict to English.
428            scope: ``"all"`` (default), ``"categories"``, ``"datagroups"``
429                or ``"series"``. ``"series"`` requires drilling into every
430                datagroup so the first call is slow (subsequent calls are
431                cached).
432
433        Returns:
434            DataFrame with at least ``hit_type``, ``CODE``, ``NAME_TR``,
435            ``NAME_EN`` columns. Type-specific extras included where useful.
436        """
437        if not term or not isinstance(term, str):
438            raise ValueError("search term is required")
439        needle = term.strip().lower()
440        if not needle:
441            return pd.DataFrame()
442        results: list[dict] = []
443
444        cats = self._provider.get_categories()
445        if scope in {"all", "categories"}:
446            for c in cats:
447                tr = (c.get("TOPIC_TITLE_TR") or "").lower()
448                en = (c.get("TOPIC_TITLE_ENG") or "").lower()
449                hit = (lang == "tr" and (needle in tr or needle in en)) or (
450                    lang == "en" and needle in en
451                )
452                if hit:
453                    results.append({
454                        "hit_type": "category",
455                        "CODE": c.get("CATEGORY_ID"),
456                        "NAME_TR": c.get("TOPIC_TITLE_TR"),
457                        "NAME_EN": c.get("TOPIC_TITLE_ENG"),
458                    })
459        if scope in {"all", "datagroups"}:
460            for c in cats:
461                for dg in c.get("DATAGROUPS", []) or []:
462                    tr = (dg.get("DATAGROUP_TYPE") or "").lower()
463                    en = (dg.get("DATAGROUP_TYPE_ENG") or "").lower()
464                    hit = (lang == "tr" and (needle in tr or needle in en)) or (
465                        lang == "en" and needle in en
466                    )
467                    if hit:
468                        results.append({
469                            "hit_type": "datagroup",
470                            "CODE": dg.get("DATAGROUP_CODE"),
471                            "NAME_TR": dg.get("DATAGROUP_TYPE"),
472                            "NAME_EN": dg.get("DATAGROUP_TYPE_ENG"),
473                            "CATEGORY_TR": c.get("TOPIC_TITLE_TR"),
474                            "FREQUENCY_STR": dg.get("FREQUENCY_STR"),
475                        })
476        if scope in {"all", "series"}:
477            # Only fully scan the series tree when explicitly requested or when
478            # category/datagroup scans yielded no candidates.
479            for c in cats:
480                for dg in c.get("DATAGROUPS", []) or []:
481                    dg_code = dg.get("DATAGROUP_CODE")
482                    if not dg_code:
483                        continue
484                    series_list = self._provider.get_series_list(dg_code)
485                    for s in series_list:
486                        tr = (s.get("SERIE_NAME") or "").lower()
487                        en = (s.get("SERIE_NAME_ENG") or "").lower()
488                        sc = (s.get("SERIE_CODE") or "").lower()
489                        hit = (
490                            (lang == "tr" and (needle in tr or needle in en or needle in sc))
491                            or (lang == "en" and (needle in en or needle in sc))
492                        )
493                        if hit:
494                            results.append({
495                                "hit_type": "series",
496                                "CODE": denormalize_code(s.get("SERIE_CODE", "")),
497                                "NAME_TR": s.get("SERIE_NAME"),
498                                "NAME_EN": s.get("SERIE_NAME_ENG"),
499                                "DATAGROUP_CODE": dg_code,
500                                "DATAGROUP_TR": dg.get("DATAGROUP_TYPE"),
501                                "FREQUENCY_STR": s.get("FREQUENCY_STR"),
502                            })
503        return pd.DataFrame(results)
504
505    # ----- Series & dashboards ------------------------------------------------
506
507    def series(self, code: str) -> EVDSSeries:
508        """Construct an :class:`EVDSSeries` for the given code."""
509        return EVDSSeries(code)
510
511    def dashboard(self, slug: str = "baslica-gostergeler") -> dict:
512        """Return raw dashboard payload (chart settings + metadata)."""
513        return self._provider.get_dashboard(slug)
514
515    def announcements(self) -> list[dict]:
516        """List EVDS announcements (TCMB releases, methodology updates etc.)."""
517        return self._provider.get_announcements()
518
519    def home_page_dashboards(self) -> pd.DataFrame:
520        """List the 10 TCMB-curated home-page dashboards.
521
522        TCMB hand-picks 10 dashboards that appear on the EVDS home page
523        (Reserves, Current Account, M-Aggregates, CPI, Card Spending,
524        FX Deposits, TL Deposit Rates, External Debt etc.). Each row gives
525        you the ``encoded_id`` you can pass to :meth:`dashboard_by_id` for
526        full chart data.
527        """
528        items = self._provider.get_home_page_dashboards()
529        rows = [
530            {
531                "name": d.get("dashboardName"),
532                "name_en": d.get("dashboardNameEn"),
533                "encoded_id": d.get("encodedId"),
534                "chart_count": len(d.get("chartsList") or []),
535                "screen_order": d.get("ekranSiraNo"),
536            }
537            for d in items
538        ]
539        return pd.DataFrame(rows).sort_values("screen_order").reset_index(drop=True)
540
541    def dashboard_by_id(self, encoded_id: str) -> dict:
542        """Fetch a dashboard's full payload by encoded id.
543
544        Use ``encoded_id`` from :meth:`home_page_dashboards` to drill into
545        any of the 10 hand-picked dashboards.
546        """
547        return self._provider.get_dashboard_by_encoded_id(encoded_id)
548
549    def search_server(self, term: str) -> dict:
550        """Server-side full-text search via TCMB's official ``/searchResults``.
551
552        Faster and broader than :meth:`search` (which walks the cached
553        catalogue client-side) — the server indexes datagroup names, series
554        names, **tags**, and report pages.
555
556        Returns:
557            Dict with three keys:
558
559            - ``"datagroups"``: matching data-group records
560            - ``"series"``: matching series records (with tags)
561            - ``"reports"``: matching report-page records
562
563            Each list is capped at 100 records by the backend.
564        """
565        raw = self._provider.search_server(term)
566        # Surface English keys so the API stays consistent with the rest
567        # of borsapy (Turkish keys preserved on the inner dicts).
568        return {
569            "datagroups": raw.get("veriGruplari") or [],
570            "series": raw.get("seriler") or [],
571            "reports": raw.get("raporlar") or [],
572        }
573
574    def datagroup_data(
575        self,
576        datagroup_code: str,
577        period: str | None = None,
578        start: str | date | datetime | None = None,
579        end: str | date | datetime | None = None,
580        frequency: str | int | None = None,
581        decimals: int = 2,
582    ) -> pd.DataFrame:
583        """Fetch every series in a datagroup with one HTTP call (key required).
584
585        Mirror of TCMB's ``/datagroup=...`` REST endpoint — returns a wide
586        DataFrame with one column per series in the group. Far more efficient
587        than building a long ``series=A-B-C-...`` list manually.
588
589        Args:
590            datagroup_code: e.g. ``"bie_dkdovizgn"``. Use :meth:`datagroups`
591                to discover.
592            period: yfinance-style window (``"1y"``, ``"3mo"`` ...) — ignored
593                if start/end are given.
594            start, end: Manual date range.
595            frequency: Optional snake_case key (``"daily"``, ``"monthly"``)
596                or integer. ``None`` uses the datagroup's native frequency.
597            decimals: Decimal places for numeric formatting.
598
599        Example:
600            >>> bp.set_evds_key("...")
601            >>> df = bp.EVDS().datagroup_data("bie_dkdovizgn", period="1mo")
602            >>> df.columns  # 137 daily-FX series in one DataFrame
603        """
604        start_str, end_str = _resolve_window(period, start, end)
605        payload = self._provider.get_datagroup_data(
606            datagroup_code,
607            start=start_str,
608            end=end_str,
609            frequency=frequency,
610            decimals=decimals,
611        )
612        # Discover series codes from the payload so _frame_from_payload knows
613        # which columns to numericify.
614        rows = []
615        if isinstance(payload, dict):
616            for key in ("items", "data", "observations", "result"):
617                if isinstance(payload.get(key), list):
618                    rows = payload[key]
619                    break
620        elif isinstance(payload, list):
621            rows = payload
622        # All non-Tarih/UNIXTIME columns are series.
623        series_cols: list[str] = []
624        if rows:
625            sample_keys = list(rows[0].keys())
626            series_cols = [
627                k for k in sample_keys
628                if k.upper() not in {"TARIH", "DATE", "UNIXTIME", "DATESTRING"}
629            ]
630        return _frame_from_payload(payload, series_cols)
631
632    def __repr__(self) -> str:  # pragma: no cover
633        return "EVDS()"

Top-level EVDS interface.

Combines catalogue navigation, search and dashboards. For individual series use series() or the module-level evds_series() shortcut.

categories: pandas.core.frame.DataFrame
351    @property
352    def categories(self) -> pd.DataFrame:
353        """All EVDS top-level categories (one row per category)."""
354        cats = self._provider.get_categories()
355        rows = [
356            {
357                "CATEGORY_ID": c.get("CATEGORY_ID"),
358                "TOPIC_TITLE_TR": c.get("TOPIC_TITLE_TR"),
359                "TOPIC_TITLE_EN": c.get("TOPIC_TITLE_ENG"),
360                "PARENT_CATEGORY_ID": c.get("UST_CATEGORY_ID"),
361                "LEVEL": c.get("SEVIYE"),
362                "DATAGROUP_COUNT": len(c.get("DATAGROUPS", []) or []),
363            }
364            for c in cats
365        ]
366        return pd.DataFrame(rows)

All EVDS top-level categories (one row per category).

def datagroups(self, category_id: int | None = None) -> pandas.core.frame.DataFrame:
368    def datagroups(self, category_id: int | None = None) -> pd.DataFrame:
369        """List datagroups, optionally filtered by category.
370
371        Includes the full metadata set returned by the EVDS catalogue:
372        unit, source, last update, methodology link, revision policy link,
373        application change log link, and free-form notes (where available).
374        """
375        cats = self._provider.get_categories()
376        rows = []
377        for c in cats:
378            if category_id is not None and c.get("CATEGORY_ID") != category_id:
379                continue
380            for dg in c.get("DATAGROUPS", []) or []:
381                rows.append({
382                    "DATAGROUP_CODE": dg.get("DATAGROUP_CODE"),
383                    "DATAGROUP_TYPE": dg.get("DATAGROUP_TYPE"),
384                    "DATAGROUP_TYPE_EN": dg.get("DATAGROUP_TYPE_ENG"),
385                    "CATEGORY_ID": c.get("CATEGORY_ID"),
386                    "CATEGORY_TR": c.get("TOPIC_TITLE_TR"),
387                    "FREQUENCY": dg.get("FREQUENCY"),
388                    "FREQUENCY_STR": dg.get("FREQUENCY_STR"),
389                    "UNIT_TR": dg.get("BIRIMI"),
390                    "UNIT_EN": dg.get("BIRIMI_EN"),
391                    "DATA_SOURCE": dg.get("DATASOURCE"),
392                    "DATA_SOURCE_EN": dg.get("DATASOURCE_ENG"),
393                    "LAST_UPDATED": dg.get("LAST_UPDATED"),
394                    "METADATA_LINK": dg.get("METADATA_LINK"),
395                    "METADATA_LINK_EN": dg.get("METADATA_LINK_ENG"),
396                    "REV_POL_LINK": dg.get("REV_POL_LINK"),
397                    "REV_POL_LINK_EN": dg.get("REV_POL_LINK_ENG"),
398                    "APP_CHA_LINK": dg.get("APP_CHA_LINK"),
399                    "APP_CHA_LINK_EN": dg.get("APP_CHA_LINK_ENG"),
400                    "NOTE": dg.get("NOTE"),
401                    "NOTE_EN": dg.get("NOTE_ENG"),
402                })
403        return pd.DataFrame(rows)

List datagroups, optionally filtered by category.

Includes the full metadata set returned by the EVDS catalogue: unit, source, last update, methodology link, revision policy link, application change log link, and free-form notes (where available).

def series_in_group(self, datagroup_code: str) -> pandas.core.frame.DataFrame:
405    def series_in_group(self, datagroup_code: str) -> pd.DataFrame:
406        """List all series that belong to a datagroup."""
407        rows = self._provider.get_series_list(datagroup_code)
408        if not rows:
409            return pd.DataFrame()
410        # Surface dot-form codes for friendlier UX.
411        for r in rows:
412            if r.get("SERIE_CODE"):
413                r["SERIE_CODE"] = denormalize_code(r["SERIE_CODE"])
414        return pd.DataFrame(rows)

List all series that belong to a datagroup.

def search( self, term: str, lang: str = 'tr', scope: str = 'all') -> pandas.core.frame.DataFrame:
416    def search(
417        self,
418        term: str,
419        lang: str = "tr",
420        scope: str = "all",
421    ) -> pd.DataFrame:
422        """Full-text search across categories, datagroups and series.
423
424        Args:
425            term: Substring to look for (case-insensitive).
426            lang: ``"tr"`` (default) — searches Turkish + English titles.
427                ``"en"`` — restrict to English.
428            scope: ``"all"`` (default), ``"categories"``, ``"datagroups"``
429                or ``"series"``. ``"series"`` requires drilling into every
430                datagroup so the first call is slow (subsequent calls are
431                cached).
432
433        Returns:
434            DataFrame with at least ``hit_type``, ``CODE``, ``NAME_TR``,
435            ``NAME_EN`` columns. Type-specific extras included where useful.
436        """
437        if not term or not isinstance(term, str):
438            raise ValueError("search term is required")
439        needle = term.strip().lower()
440        if not needle:
441            return pd.DataFrame()
442        results: list[dict] = []
443
444        cats = self._provider.get_categories()
445        if scope in {"all", "categories"}:
446            for c in cats:
447                tr = (c.get("TOPIC_TITLE_TR") or "").lower()
448                en = (c.get("TOPIC_TITLE_ENG") or "").lower()
449                hit = (lang == "tr" and (needle in tr or needle in en)) or (
450                    lang == "en" and needle in en
451                )
452                if hit:
453                    results.append({
454                        "hit_type": "category",
455                        "CODE": c.get("CATEGORY_ID"),
456                        "NAME_TR": c.get("TOPIC_TITLE_TR"),
457                        "NAME_EN": c.get("TOPIC_TITLE_ENG"),
458                    })
459        if scope in {"all", "datagroups"}:
460            for c in cats:
461                for dg in c.get("DATAGROUPS", []) or []:
462                    tr = (dg.get("DATAGROUP_TYPE") or "").lower()
463                    en = (dg.get("DATAGROUP_TYPE_ENG") or "").lower()
464                    hit = (lang == "tr" and (needle in tr or needle in en)) or (
465                        lang == "en" and needle in en
466                    )
467                    if hit:
468                        results.append({
469                            "hit_type": "datagroup",
470                            "CODE": dg.get("DATAGROUP_CODE"),
471                            "NAME_TR": dg.get("DATAGROUP_TYPE"),
472                            "NAME_EN": dg.get("DATAGROUP_TYPE_ENG"),
473                            "CATEGORY_TR": c.get("TOPIC_TITLE_TR"),
474                            "FREQUENCY_STR": dg.get("FREQUENCY_STR"),
475                        })
476        if scope in {"all", "series"}:
477            # Only fully scan the series tree when explicitly requested or when
478            # category/datagroup scans yielded no candidates.
479            for c in cats:
480                for dg in c.get("DATAGROUPS", []) or []:
481                    dg_code = dg.get("DATAGROUP_CODE")
482                    if not dg_code:
483                        continue
484                    series_list = self._provider.get_series_list(dg_code)
485                    for s in series_list:
486                        tr = (s.get("SERIE_NAME") or "").lower()
487                        en = (s.get("SERIE_NAME_ENG") or "").lower()
488                        sc = (s.get("SERIE_CODE") or "").lower()
489                        hit = (
490                            (lang == "tr" and (needle in tr or needle in en or needle in sc))
491                            or (lang == "en" and (needle in en or needle in sc))
492                        )
493                        if hit:
494                            results.append({
495                                "hit_type": "series",
496                                "CODE": denormalize_code(s.get("SERIE_CODE", "")),
497                                "NAME_TR": s.get("SERIE_NAME"),
498                                "NAME_EN": s.get("SERIE_NAME_ENG"),
499                                "DATAGROUP_CODE": dg_code,
500                                "DATAGROUP_TR": dg.get("DATAGROUP_TYPE"),
501                                "FREQUENCY_STR": s.get("FREQUENCY_STR"),
502                            })
503        return pd.DataFrame(results)

Full-text search across categories, datagroups and series.

Args: term: Substring to look for (case-insensitive). lang: "tr" (default) — searches Turkish + English titles. "en" — restrict to English. scope: "all" (default), "categories", "datagroups" or "series". "series" requires drilling into every datagroup so the first call is slow (subsequent calls are cached).

Returns: DataFrame with at least hit_type, CODE, NAME_TR, NAME_EN columns. Type-specific extras included where useful.

def series(self, code: str) -> EVDSSeries:
507    def series(self, code: str) -> EVDSSeries:
508        """Construct an :class:`EVDSSeries` for the given code."""
509        return EVDSSeries(code)

Construct an EVDSSeries for the given code.

def dashboard(self, slug: str = 'baslica-gostergeler') -> dict:
511    def dashboard(self, slug: str = "baslica-gostergeler") -> dict:
512        """Return raw dashboard payload (chart settings + metadata)."""
513        return self._provider.get_dashboard(slug)

Return raw dashboard payload (chart settings + metadata).

def announcements(self) -> list[dict]:
515    def announcements(self) -> list[dict]:
516        """List EVDS announcements (TCMB releases, methodology updates etc.)."""
517        return self._provider.get_announcements()

List EVDS announcements (TCMB releases, methodology updates etc.).

def home_page_dashboards(self) -> pandas.core.frame.DataFrame:
519    def home_page_dashboards(self) -> pd.DataFrame:
520        """List the 10 TCMB-curated home-page dashboards.
521
522        TCMB hand-picks 10 dashboards that appear on the EVDS home page
523        (Reserves, Current Account, M-Aggregates, CPI, Card Spending,
524        FX Deposits, TL Deposit Rates, External Debt etc.). Each row gives
525        you the ``encoded_id`` you can pass to :meth:`dashboard_by_id` for
526        full chart data.
527        """
528        items = self._provider.get_home_page_dashboards()
529        rows = [
530            {
531                "name": d.get("dashboardName"),
532                "name_en": d.get("dashboardNameEn"),
533                "encoded_id": d.get("encodedId"),
534                "chart_count": len(d.get("chartsList") or []),
535                "screen_order": d.get("ekranSiraNo"),
536            }
537            for d in items
538        ]
539        return pd.DataFrame(rows).sort_values("screen_order").reset_index(drop=True)

List the 10 TCMB-curated home-page dashboards.

TCMB hand-picks 10 dashboards that appear on the EVDS home page (Reserves, Current Account, M-Aggregates, CPI, Card Spending, FX Deposits, TL Deposit Rates, External Debt etc.). Each row gives you the encoded_id you can pass to dashboard_by_id() for full chart data.

def dashboard_by_id(self, encoded_id: str) -> dict:
541    def dashboard_by_id(self, encoded_id: str) -> dict:
542        """Fetch a dashboard's full payload by encoded id.
543
544        Use ``encoded_id`` from :meth:`home_page_dashboards` to drill into
545        any of the 10 hand-picked dashboards.
546        """
547        return self._provider.get_dashboard_by_encoded_id(encoded_id)

Fetch a dashboard's full payload by encoded id.

Use encoded_id from home_page_dashboards() to drill into any of the 10 hand-picked dashboards.

def search_server(self, term: str) -> dict:
549    def search_server(self, term: str) -> dict:
550        """Server-side full-text search via TCMB's official ``/searchResults``.
551
552        Faster and broader than :meth:`search` (which walks the cached
553        catalogue client-side) — the server indexes datagroup names, series
554        names, **tags**, and report pages.
555
556        Returns:
557            Dict with three keys:
558
559            - ``"datagroups"``: matching data-group records
560            - ``"series"``: matching series records (with tags)
561            - ``"reports"``: matching report-page records
562
563            Each list is capped at 100 records by the backend.
564        """
565        raw = self._provider.search_server(term)
566        # Surface English keys so the API stays consistent with the rest
567        # of borsapy (Turkish keys preserved on the inner dicts).
568        return {
569            "datagroups": raw.get("veriGruplari") or [],
570            "series": raw.get("seriler") or [],
571            "reports": raw.get("raporlar") or [],
572        }

Server-side full-text search via TCMB's official /searchResults.

Faster and broader than search() (which walks the cached catalogue client-side) — the server indexes datagroup names, series names, tags, and report pages.

Returns: Dict with three keys:

- ``"datagroups"``: matching data-group records
- ``"series"``: matching series records (with tags)
- ``"reports"``: matching report-page records

Each list is capped at 100 records by the backend.
def datagroup_data( self, datagroup_code: str, period: str | None = None, start: str | datetime.date | datetime.datetime | None = None, end: str | datetime.date | datetime.datetime | None = None, frequency: str | int | None = None, decimals: int = 2) -> pandas.core.frame.DataFrame:
574    def datagroup_data(
575        self,
576        datagroup_code: str,
577        period: str | None = None,
578        start: str | date | datetime | None = None,
579        end: str | date | datetime | None = None,
580        frequency: str | int | None = None,
581        decimals: int = 2,
582    ) -> pd.DataFrame:
583        """Fetch every series in a datagroup with one HTTP call (key required).
584
585        Mirror of TCMB's ``/datagroup=...`` REST endpoint — returns a wide
586        DataFrame with one column per series in the group. Far more efficient
587        than building a long ``series=A-B-C-...`` list manually.
588
589        Args:
590            datagroup_code: e.g. ``"bie_dkdovizgn"``. Use :meth:`datagroups`
591                to discover.
592            period: yfinance-style window (``"1y"``, ``"3mo"`` ...) — ignored
593                if start/end are given.
594            start, end: Manual date range.
595            frequency: Optional snake_case key (``"daily"``, ``"monthly"``)
596                or integer. ``None`` uses the datagroup's native frequency.
597            decimals: Decimal places for numeric formatting.
598
599        Example:
600            >>> bp.set_evds_key("...")
601            >>> df = bp.EVDS().datagroup_data("bie_dkdovizgn", period="1mo")
602            >>> df.columns  # 137 daily-FX series in one DataFrame
603        """
604        start_str, end_str = _resolve_window(period, start, end)
605        payload = self._provider.get_datagroup_data(
606            datagroup_code,
607            start=start_str,
608            end=end_str,
609            frequency=frequency,
610            decimals=decimals,
611        )
612        # Discover series codes from the payload so _frame_from_payload knows
613        # which columns to numericify.
614        rows = []
615        if isinstance(payload, dict):
616            for key in ("items", "data", "observations", "result"):
617                if isinstance(payload.get(key), list):
618                    rows = payload[key]
619                    break
620        elif isinstance(payload, list):
621            rows = payload
622        # All non-Tarih/UNIXTIME columns are series.
623        series_cols: list[str] = []
624        if rows:
625            sample_keys = list(rows[0].keys())
626            series_cols = [
627                k for k in sample_keys
628                if k.upper() not in {"TARIH", "DATE", "UNIXTIME", "DATESTRING"}
629            ]
630        return _frame_from_payload(payload, series_cols)

Fetch every series in a datagroup with one HTTP call (key required).

Mirror of TCMB's /datagroup=... REST endpoint — returns a wide DataFrame with one column per series in the group. Far more efficient than building a long series=A-B-C-... list manually.

Args: datagroup_code: e.g. "bie_dkdovizgn". Use datagroups() to discover. period: yfinance-style window ("1y", "3mo" ...) — ignored if start/end are given. start, end: Manual date range. frequency: Optional snake_case key ("daily", "monthly") or integer. None uses the datagroup's native frequency. decimals: Decimal places for numeric formatting.

Example:

bp.set_evds_key("...") df = bp.EVDS().datagroup_data("bie_dkdovizgn", period="1mo") df.columns # 137 daily-FX series in one DataFrame

class EVDSSeries:
202class EVDSSeries:
203    """Single time series from EVDS.
204
205    The user-facing API mirrors :class:`borsapy.Ticker`: a small lazy-loaded
206    wrapper with ``info`` / ``range`` / ``history()``.
207    """
208
209    def __init__(self, code: str) -> None:
210        if not code or not isinstance(code, str):
211            raise ValueError("EVDS series code is required (e.g. 'TP.DK.USD.A')")
212        self._code_user = code
213        self._code_normalized = normalize_code(code)
214        self._provider = get_evds_provider()
215        self._info_cache: dict | None = None
216
217    @property
218    def code(self) -> str:
219        """User-facing series code (dot-separated, e.g. ``"TP.DK.USD.A"``)."""
220        return self._code_user
221
222    @property
223    def info(self) -> dict:
224        """Catalogue metadata: SERIE_NAME, FREQUENCY_STR, BIRIMI, etc."""
225        if self._info_cache is not None:
226            return self._info_cache
227        located = self._provider.find_series(self._code_user)
228        if not located:
229            raise DataNotAvailableError(
230                f"EVDS series not found: {self._code_user}"
231            )
232        # Surface user-facing dot codes in the result.
233        info = dict(located)
234        info["SERIE_CODE"] = denormalize_code(info.get("SERIE_CODE", self._code_user))
235        # Embed convenience fields without leaking internal keys.
236        dg = info.pop("_datagroup", {}) or {}
237        cat = info.pop("_category", {}) or {}
238        info.setdefault("DATAGROUP_CODE", dg.get("DATAGROUP_CODE"))
239        info.setdefault("DATAGROUP_TYPE", dg.get("DATAGROUP_TYPE"))
240        info.setdefault("CATEGORY_ID", cat.get("CATEGORY_ID"))
241        info.setdefault("CATEGORY_TR", cat.get("TOPIC_TITLE_TR"))
242        info.setdefault("CATEGORY_EN", cat.get("TOPIC_TITLE_ENG"))
243        self._info_cache = info
244        return info
245
246    @property
247    def datagroup(self) -> str | None:
248        """Datagroup code that owns this series."""
249        return self.info.get("DATAGROUP_CODE")
250
251    @property
252    def native_frequency(self) -> str | None:
253        """The series's native frequency (snake_case key in :data:`FREQUENCY`)."""
254        info = self.info
255        # Try numeric metadata first (FREQUENCY in datagroup).
256        raw = info.get("FREQUENCY")
257        if isinstance(raw, int):
258            normalized = NUMERIC_FREQ_NORMALIZE.get(raw, raw)
259            for key, val in FREQUENCY.items():
260                if val == normalized:
261                    return key
262        # Fallback: parse FREQUENCY_STR like "AYLIK", "GÜNLÜK".
263        s = (info.get("FREQUENCY_STR") or "").upper()
264        mapping = {
265            "GÜNLÜK": "daily",
266            "İŞ GÜNÜ": "workday",
267            "HAFTALIK": "weekly",
268            "İKİ HAFTALIK": "biweekly",
269            "AYLIK": "monthly",
270            "ÜÇ AYLIK": "quarterly",
271            "ALTI AYLIK": "semiannual",
272            "YILLIK": "annual",
273        }
274        return mapping.get(s)
275
276    @property
277    def range(self) -> tuple[pd.Timestamp | None, pd.Timestamp | None]:
278        """Min/max available observation dates as ``(start, end)``."""
279        freq = self.native_frequency or "monthly"
280        info = self.info
281        dg = info.get("DATAGROUP_CODE", "")
282        rng = self._provider.get_series_range([self._code_user], [dg], frequency=freq)
283        entry = rng.get(self._code_normalized.upper(), {})
284        return _parse_evds_date(entry.get("start")), _parse_evds_date(entry.get("end"))
285
286    def history(
287        self,
288        period: str = "1y",
289        start: str | date | datetime | None = None,
290        end: str | date | datetime | None = None,
291        frequency: str | int | None = None,
292        aggregation: str = "avg",
293        formula: str = "level",
294        decimals: int = 2,
295        decimal_separator: str = ".",
296    ) -> pd.DataFrame:
297        """Fetch observation history.
298
299        Args:
300            period: yfinance-style period (``"1mo"``, ``"3mo"``, ``"1y"``,
301                ``"5y"``, ``"max"``, ``"ytd"`` ...). Ignored if start/end are
302                supplied.
303            start: Start date (``YYYY-MM-DD``, ``date``, ``datetime``).
304            end: End date (defaults to ``"01-01-2999"`` — TCMB-recommended
305                "always current" sentinel — when start is given).
306            frequency: snake_case (``"monthly"``) or integer 1..8. ``None``
307                means use the series's native frequency.
308            aggregation: ``avg|min|max|first|last|sum``.
309            formula: ``"level"``, ``"pct_change"``, ``"yoy_pct"``, etc.
310                See :data:`borsapy._providers.evds.FORMULA`.
311            decimals: decimal places for backend rounding.
312            decimal_separator: ``"."`` (default) or ``","`` — useful for
313                Turkish-locale Excel exports.
314
315        Returns:
316            DataFrame indexed by Date with one ``Value`` column.
317        """
318        start_str, end_str = _resolve_window(period, start, end)
319        freq = frequency or self.native_frequency or "monthly"
320        payload = self._provider.get_series_data(
321            [self._code_user],
322            start=start_str,
323            end=end_str,
324            frequency=freq,
325            aggregation=aggregation,
326            formula=formula,
327            decimals=decimals,
328            decimal_separator=decimal_separator,
329        )
330        return _frame_from_payload(payload, [self._code_user])
331
332    def __repr__(self) -> str:  # pragma: no cover - cosmetic
333        return f"EVDSSeries({self._code_user!r})"

Single time series from EVDS.

The user-facing API mirrors borsapy.Ticker: a small lazy-loaded wrapper with info / range / history().

EVDSSeries(code: str)
209    def __init__(self, code: str) -> None:
210        if not code or not isinstance(code, str):
211            raise ValueError("EVDS series code is required (e.g. 'TP.DK.USD.A')")
212        self._code_user = code
213        self._code_normalized = normalize_code(code)
214        self._provider = get_evds_provider()
215        self._info_cache: dict | None = None
code: str
217    @property
218    def code(self) -> str:
219        """User-facing series code (dot-separated, e.g. ``"TP.DK.USD.A"``)."""
220        return self._code_user

User-facing series code (dot-separated, e.g. "TP.DK.USD.A").

info: dict
222    @property
223    def info(self) -> dict:
224        """Catalogue metadata: SERIE_NAME, FREQUENCY_STR, BIRIMI, etc."""
225        if self._info_cache is not None:
226            return self._info_cache
227        located = self._provider.find_series(self._code_user)
228        if not located:
229            raise DataNotAvailableError(
230                f"EVDS series not found: {self._code_user}"
231            )
232        # Surface user-facing dot codes in the result.
233        info = dict(located)
234        info["SERIE_CODE"] = denormalize_code(info.get("SERIE_CODE", self._code_user))
235        # Embed convenience fields without leaking internal keys.
236        dg = info.pop("_datagroup", {}) or {}
237        cat = info.pop("_category", {}) or {}
238        info.setdefault("DATAGROUP_CODE", dg.get("DATAGROUP_CODE"))
239        info.setdefault("DATAGROUP_TYPE", dg.get("DATAGROUP_TYPE"))
240        info.setdefault("CATEGORY_ID", cat.get("CATEGORY_ID"))
241        info.setdefault("CATEGORY_TR", cat.get("TOPIC_TITLE_TR"))
242        info.setdefault("CATEGORY_EN", cat.get("TOPIC_TITLE_ENG"))
243        self._info_cache = info
244        return info

Catalogue metadata: SERIE_NAME, FREQUENCY_STR, BIRIMI, etc.

datagroup: str | None
246    @property
247    def datagroup(self) -> str | None:
248        """Datagroup code that owns this series."""
249        return self.info.get("DATAGROUP_CODE")

Datagroup code that owns this series.

native_frequency: str | None
251    @property
252    def native_frequency(self) -> str | None:
253        """The series's native frequency (snake_case key in :data:`FREQUENCY`)."""
254        info = self.info
255        # Try numeric metadata first (FREQUENCY in datagroup).
256        raw = info.get("FREQUENCY")
257        if isinstance(raw, int):
258            normalized = NUMERIC_FREQ_NORMALIZE.get(raw, raw)
259            for key, val in FREQUENCY.items():
260                if val == normalized:
261                    return key
262        # Fallback: parse FREQUENCY_STR like "AYLIK", "GÜNLÜK".
263        s = (info.get("FREQUENCY_STR") or "").upper()
264        mapping = {
265            "GÜNLÜK": "daily",
266            "İŞ GÜNÜ": "workday",
267            "HAFTALIK": "weekly",
268            "İKİ HAFTALIK": "biweekly",
269            "AYLIK": "monthly",
270            "ÜÇ AYLIK": "quarterly",
271            "ALTI AYLIK": "semiannual",
272            "YILLIK": "annual",
273        }
274        return mapping.get(s)

The series's native frequency (snake_case key in FREQUENCY).

range: tuple[pandas._libs.tslibs.timestamps.Timestamp | None, pandas._libs.tslibs.timestamps.Timestamp | None]
276    @property
277    def range(self) -> tuple[pd.Timestamp | None, pd.Timestamp | None]:
278        """Min/max available observation dates as ``(start, end)``."""
279        freq = self.native_frequency or "monthly"
280        info = self.info
281        dg = info.get("DATAGROUP_CODE", "")
282        rng = self._provider.get_series_range([self._code_user], [dg], frequency=freq)
283        entry = rng.get(self._code_normalized.upper(), {})
284        return _parse_evds_date(entry.get("start")), _parse_evds_date(entry.get("end"))

Min/max available observation dates as (start, end).

def history( self, period: str = '1y', start: str | datetime.date | datetime.datetime | None = None, end: str | datetime.date | datetime.datetime | None = None, frequency: str | int | None = None, aggregation: str = 'avg', formula: str = 'level', decimals: int = 2, decimal_separator: str = '.') -> pandas.core.frame.DataFrame:
286    def history(
287        self,
288        period: str = "1y",
289        start: str | date | datetime | None = None,
290        end: str | date | datetime | None = None,
291        frequency: str | int | None = None,
292        aggregation: str = "avg",
293        formula: str = "level",
294        decimals: int = 2,
295        decimal_separator: str = ".",
296    ) -> pd.DataFrame:
297        """Fetch observation history.
298
299        Args:
300            period: yfinance-style period (``"1mo"``, ``"3mo"``, ``"1y"``,
301                ``"5y"``, ``"max"``, ``"ytd"`` ...). Ignored if start/end are
302                supplied.
303            start: Start date (``YYYY-MM-DD``, ``date``, ``datetime``).
304            end: End date (defaults to ``"01-01-2999"`` — TCMB-recommended
305                "always current" sentinel — when start is given).
306            frequency: snake_case (``"monthly"``) or integer 1..8. ``None``
307                means use the series's native frequency.
308            aggregation: ``avg|min|max|first|last|sum``.
309            formula: ``"level"``, ``"pct_change"``, ``"yoy_pct"``, etc.
310                See :data:`borsapy._providers.evds.FORMULA`.
311            decimals: decimal places for backend rounding.
312            decimal_separator: ``"."`` (default) or ``","`` — useful for
313                Turkish-locale Excel exports.
314
315        Returns:
316            DataFrame indexed by Date with one ``Value`` column.
317        """
318        start_str, end_str = _resolve_window(period, start, end)
319        freq = frequency or self.native_frequency or "monthly"
320        payload = self._provider.get_series_data(
321            [self._code_user],
322            start=start_str,
323            end=end_str,
324            frequency=freq,
325            aggregation=aggregation,
326            formula=formula,
327            decimals=decimals,
328            decimal_separator=decimal_separator,
329        )
330        return _frame_from_payload(payload, [self._code_user])

Fetch observation history.

Args: period: yfinance-style period ("1mo", "3mo", "1y", "5y", "max", "ytd" ...). Ignored if start/end are supplied. start: Start date (YYYY-MM-DD, date, datetime). end: End date (defaults to "01-01-2999" — TCMB-recommended "always current" sentinel — when start is given). frequency: snake_case ("monthly") or integer 1..8. None means use the series's native frequency. aggregation: avg|min|max|first|last|sum. formula: "level", "pct_change", "yoy_pct", etc. See borsapy._providers.evds.FORMULA. decimals: decimal places for backend rounding. decimal_separator: "." (default) or "," — useful for Turkish-locale Excel exports.

Returns: DataFrame indexed by Date with one Value column.

class EconomicCalendar:
 11class EconomicCalendar:
 12    """
 13    A yfinance-like interface for economic calendar data.
 14
 15    Data source: doviz.com
 16
 17    Examples:
 18        >>> import borsapy as bp
 19        >>> cal = bp.EconomicCalendar()
 20        >>> cal.events(period="1w")  # This week's events
 21                         Date      Time Country  Importance              Event Actual Forecast Previous
 22        0 2024-01-15  10:00:00  Türkiye        high  Enflasyon (YoY)  64.77%   65.00%   61.98%
 23        ...
 24
 25        >>> cal.today()  # Today's events
 26        >>> cal.events(country="TR", importance="high")  # High importance TR events
 27    """
 28
 29    # Valid country codes
 30    COUNTRIES = ["TR", "US", "EU", "DE", "GB", "JP", "CN", "FR", "IT", "CA", "AU", "CH"]
 31
 32    def __init__(self):
 33        """Initialize EconomicCalendar."""
 34        self._provider = get_calendar_provider()
 35
 36    def events(
 37        self,
 38        period: str = "1w",
 39        start: datetime | str | None = None,
 40        end: datetime | str | None = None,
 41        country: str | list[str] | None = None,
 42        importance: str | None = None,
 43    ) -> pd.DataFrame:
 44        """
 45        Get economic calendar events.
 46
 47        Args:
 48            period: How much data to fetch. Valid periods: 1d, 1w, 2w, 1mo.
 49                    Ignored if start is provided.
 50            start: Start date (string or datetime).
 51            end: End date (string or datetime). Defaults to start + period.
 52            country: Country code(s) to filter by (TR, US, EU, etc.).
 53                     Can be a single code or list of codes.
 54                     Defaults to ['TR', 'US'].
 55            importance: Filter by importance level ('low', 'mid', 'high').
 56
 57        Returns:
 58            DataFrame with columns: Date, Time, Country, Importance, Event,
 59                                    Actual, Forecast, Previous, Period.
 60
 61        Examples:
 62            >>> cal = EconomicCalendar()
 63            >>> cal.events(period="1w")  # Next 7 days
 64            >>> cal.events(country="TR", importance="high")  # High importance TR events
 65            >>> cal.events(country=["TR", "US", "EU"])  # Multiple countries
 66            >>> cal.events(start="2024-01-01", end="2024-01-31")  # Date range
 67        """
 68        # Parse dates
 69        start_dt = self._parse_date(start) if start else None
 70        end_dt = self._parse_date(end) if end else None
 71
 72        # If no start, use today
 73        if start_dt is None:
 74            start_dt = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
 75
 76        # If no end, calculate from period
 77        if end_dt is None:
 78            days = {"1d": 1, "1w": 7, "2w": 14, "1mo": 30}.get(period, 7)
 79            end_dt = start_dt + timedelta(days=days)
 80
 81        # Parse country parameter
 82        countries = self._parse_countries(country)
 83
 84        # Fetch events
 85        events = self._provider.get_economic_calendar(
 86            start=start_dt,
 87            end=end_dt,
 88            countries=countries,
 89            importance=importance,
 90        )
 91
 92        # Convert to DataFrame
 93        if not events:
 94            return pd.DataFrame(
 95                columns=[
 96                    "Date",
 97                    "Time",
 98                    "Country",
 99                    "Importance",
100                    "Event",
101                    "Actual",
102                    "Forecast",
103                    "Previous",
104                    "Period",
105                ]
106            )
107
108        df = pd.DataFrame(events)
109
110        # Rename columns
111        df = df.rename(
112            columns={
113                "date": "Date",
114                "time": "Time",
115                "country": "Country",
116                "importance": "Importance",
117                "event": "Event",
118                "actual": "Actual",
119                "forecast": "Forecast",
120                "previous": "Previous",
121                "period": "Period",
122            }
123        )
124
125        # Drop internal columns
126        if "country_code" in df.columns:
127            df = df.drop(columns=["country_code"])
128
129        # Reorder columns
130        column_order = [
131            "Date",
132            "Time",
133            "Country",
134            "Importance",
135            "Event",
136            "Actual",
137            "Forecast",
138            "Previous",
139            "Period",
140        ]
141        df = df[[c for c in column_order if c in df.columns]]
142
143        return df
144
145    def today(
146        self,
147        country: str | list[str] | None = None,
148        importance: str | None = None,
149    ) -> pd.DataFrame:
150        """
151        Get today's economic events.
152
153        Args:
154            country: Country code(s) to filter by.
155            importance: Filter by importance level.
156
157        Returns:
158            DataFrame with today's economic events.
159        """
160        return self.events(period="1d", country=country, importance=importance)
161
162    def this_week(
163        self,
164        country: str | list[str] | None = None,
165        importance: str | None = None,
166    ) -> pd.DataFrame:
167        """
168        Get this week's economic events.
169
170        Args:
171            country: Country code(s) to filter by.
172            importance: Filter by importance level.
173
174        Returns:
175            DataFrame with this week's economic events.
176        """
177        return self.events(period="1w", country=country, importance=importance)
178
179    def this_month(
180        self,
181        country: str | list[str] | None = None,
182        importance: str | None = None,
183    ) -> pd.DataFrame:
184        """
185        Get this month's economic events.
186
187        Args:
188            country: Country code(s) to filter by.
189            importance: Filter by importance level.
190
191        Returns:
192            DataFrame with this month's economic events.
193        """
194        return self.events(period="1mo", country=country, importance=importance)
195
196    def high_importance(
197        self,
198        period: str = "1w",
199        country: str | list[str] | None = None,
200    ) -> pd.DataFrame:
201        """
202        Get high importance events only.
203
204        Args:
205            period: Time period (1d, 1w, 2w, 1mo).
206            country: Country code(s) to filter by.
207
208        Returns:
209            DataFrame with high importance events.
210        """
211        return self.events(period=period, country=country, importance="high")
212
213    @staticmethod
214    def countries() -> list[str]:
215        """
216        Get list of supported country codes.
217
218        Returns:
219            List of country codes.
220
221        Examples:
222            >>> EconomicCalendar.countries()
223            ['TR', 'US', 'EU', 'DE', 'GB', 'JP', 'CN', ...]
224        """
225        return EconomicCalendar.COUNTRIES.copy()
226
227    def _parse_date(self, date: str | datetime) -> datetime:
228        """Parse a date string to datetime."""
229        if isinstance(date, datetime):
230            return date
231        for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"]:
232            try:
233                return datetime.strptime(date, fmt)
234            except ValueError:
235                continue
236        raise ValueError(f"Could not parse date: {date}")
237
238    def _parse_countries(self, country: str | list[str] | None) -> list[str]:
239        """Parse country parameter to list of codes."""
240        if country is None:
241            return ["TR", "US"]
242        if isinstance(country, str):
243            return [country.upper()]
244        return [c.upper() for c in country]
245
246    def __repr__(self) -> str:
247        return "EconomicCalendar()"

A yfinance-like interface for economic calendar data.

Data source: doviz.com

Examples:

import borsapy as bp cal = bp.EconomicCalendar() cal.events(period="1w") # This week's events Date Time Country Importance Event Actual Forecast Previous 0 2024-01-15 10:00:00 Türkiye high Enflasyon (YoY) 64.77% 65.00% 61.98% ...

>>> cal.today()  # Today's events
>>> cal.events(country="TR", importance="high")  # High importance TR events
EconomicCalendar()
32    def __init__(self):
33        """Initialize EconomicCalendar."""
34        self._provider = get_calendar_provider()

Initialize EconomicCalendar.

COUNTRIES = ['TR', 'US', 'EU', 'DE', 'GB', 'JP', 'CN', 'FR', 'IT', 'CA', 'AU', 'CH']
def events( self, period: str = '1w', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None, country: str | list[str] | None = None, importance: str | None = None) -> pandas.core.frame.DataFrame:
 36    def events(
 37        self,
 38        period: str = "1w",
 39        start: datetime | str | None = None,
 40        end: datetime | str | None = None,
 41        country: str | list[str] | None = None,
 42        importance: str | None = None,
 43    ) -> pd.DataFrame:
 44        """
 45        Get economic calendar events.
 46
 47        Args:
 48            period: How much data to fetch. Valid periods: 1d, 1w, 2w, 1mo.
 49                    Ignored if start is provided.
 50            start: Start date (string or datetime).
 51            end: End date (string or datetime). Defaults to start + period.
 52            country: Country code(s) to filter by (TR, US, EU, etc.).
 53                     Can be a single code or list of codes.
 54                     Defaults to ['TR', 'US'].
 55            importance: Filter by importance level ('low', 'mid', 'high').
 56
 57        Returns:
 58            DataFrame with columns: Date, Time, Country, Importance, Event,
 59                                    Actual, Forecast, Previous, Period.
 60
 61        Examples:
 62            >>> cal = EconomicCalendar()
 63            >>> cal.events(period="1w")  # Next 7 days
 64            >>> cal.events(country="TR", importance="high")  # High importance TR events
 65            >>> cal.events(country=["TR", "US", "EU"])  # Multiple countries
 66            >>> cal.events(start="2024-01-01", end="2024-01-31")  # Date range
 67        """
 68        # Parse dates
 69        start_dt = self._parse_date(start) if start else None
 70        end_dt = self._parse_date(end) if end else None
 71
 72        # If no start, use today
 73        if start_dt is None:
 74            start_dt = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
 75
 76        # If no end, calculate from period
 77        if end_dt is None:
 78            days = {"1d": 1, "1w": 7, "2w": 14, "1mo": 30}.get(period, 7)
 79            end_dt = start_dt + timedelta(days=days)
 80
 81        # Parse country parameter
 82        countries = self._parse_countries(country)
 83
 84        # Fetch events
 85        events = self._provider.get_economic_calendar(
 86            start=start_dt,
 87            end=end_dt,
 88            countries=countries,
 89            importance=importance,
 90        )
 91
 92        # Convert to DataFrame
 93        if not events:
 94            return pd.DataFrame(
 95                columns=[
 96                    "Date",
 97                    "Time",
 98                    "Country",
 99                    "Importance",
100                    "Event",
101                    "Actual",
102                    "Forecast",
103                    "Previous",
104                    "Period",
105                ]
106            )
107
108        df = pd.DataFrame(events)
109
110        # Rename columns
111        df = df.rename(
112            columns={
113                "date": "Date",
114                "time": "Time",
115                "country": "Country",
116                "importance": "Importance",
117                "event": "Event",
118                "actual": "Actual",
119                "forecast": "Forecast",
120                "previous": "Previous",
121                "period": "Period",
122            }
123        )
124
125        # Drop internal columns
126        if "country_code" in df.columns:
127            df = df.drop(columns=["country_code"])
128
129        # Reorder columns
130        column_order = [
131            "Date",
132            "Time",
133            "Country",
134            "Importance",
135            "Event",
136            "Actual",
137            "Forecast",
138            "Previous",
139            "Period",
140        ]
141        df = df[[c for c in column_order if c in df.columns]]
142
143        return df

Get economic calendar events.

Args: period: How much data to fetch. Valid periods: 1d, 1w, 2w, 1mo. Ignored if start is provided. start: Start date (string or datetime). end: End date (string or datetime). Defaults to start + period. country: Country code(s) to filter by (TR, US, EU, etc.). Can be a single code or list of codes. Defaults to ['TR', 'US']. importance: Filter by importance level ('low', 'mid', 'high').

Returns: DataFrame with columns: Date, Time, Country, Importance, Event, Actual, Forecast, Previous, Period.

Examples:

cal = EconomicCalendar() cal.events(period="1w") # Next 7 days cal.events(country="TR", importance="high") # High importance TR events cal.events(country=["TR", "US", "EU"]) # Multiple countries cal.events(start="2024-01-01", end="2024-01-31") # Date range

def today( self, country: str | list[str] | None = None, importance: str | None = None) -> pandas.core.frame.DataFrame:
145    def today(
146        self,
147        country: str | list[str] | None = None,
148        importance: str | None = None,
149    ) -> pd.DataFrame:
150        """
151        Get today's economic events.
152
153        Args:
154            country: Country code(s) to filter by.
155            importance: Filter by importance level.
156
157        Returns:
158            DataFrame with today's economic events.
159        """
160        return self.events(period="1d", country=country, importance=importance)

Get today's economic events.

Args: country: Country code(s) to filter by. importance: Filter by importance level.

Returns: DataFrame with today's economic events.

def this_week( self, country: str | list[str] | None = None, importance: str | None = None) -> pandas.core.frame.DataFrame:
162    def this_week(
163        self,
164        country: str | list[str] | None = None,
165        importance: str | None = None,
166    ) -> pd.DataFrame:
167        """
168        Get this week's economic events.
169
170        Args:
171            country: Country code(s) to filter by.
172            importance: Filter by importance level.
173
174        Returns:
175            DataFrame with this week's economic events.
176        """
177        return self.events(period="1w", country=country, importance=importance)

Get this week's economic events.

Args: country: Country code(s) to filter by. importance: Filter by importance level.

Returns: DataFrame with this week's economic events.

def this_month( self, country: str | list[str] | None = None, importance: str | None = None) -> pandas.core.frame.DataFrame:
179    def this_month(
180        self,
181        country: str | list[str] | None = None,
182        importance: str | None = None,
183    ) -> pd.DataFrame:
184        """
185        Get this month's economic events.
186
187        Args:
188            country: Country code(s) to filter by.
189            importance: Filter by importance level.
190
191        Returns:
192            DataFrame with this month's economic events.
193        """
194        return self.events(period="1mo", country=country, importance=importance)

Get this month's economic events.

Args: country: Country code(s) to filter by. importance: Filter by importance level.

Returns: DataFrame with this month's economic events.

def high_importance( self, period: str = '1w', country: str | list[str] | None = None) -> pandas.core.frame.DataFrame:
196    def high_importance(
197        self,
198        period: str = "1w",
199        country: str | list[str] | None = None,
200    ) -> pd.DataFrame:
201        """
202        Get high importance events only.
203
204        Args:
205            period: Time period (1d, 1w, 2w, 1mo).
206            country: Country code(s) to filter by.
207
208        Returns:
209            DataFrame with high importance events.
210        """
211        return self.events(period=period, country=country, importance="high")

Get high importance events only.

Args: period: Time period (1d, 1w, 2w, 1mo). country: Country code(s) to filter by.

Returns: DataFrame with high importance events.

@staticmethod
def countries() -> list[str]:
213    @staticmethod
214    def countries() -> list[str]:
215        """
216        Get list of supported country codes.
217
218        Returns:
219            List of country codes.
220
221        Examples:
222            >>> EconomicCalendar.countries()
223            ['TR', 'US', 'EU', 'DE', 'GB', 'JP', 'CN', ...]
224        """
225        return EconomicCalendar.COUNTRIES.copy()

Get list of supported country codes.

Returns: List of country codes.

Examples:

EconomicCalendar.countries() ['TR', 'US', 'EU', 'DE', 'GB', 'JP', 'CN', ...]

class Screener:
 12class Screener:
 13    """
 14    A yfinance-like interface for BIST stock screening.
 15
 16    Data source: İş Yatırım
 17
 18    Examples:
 19        >>> import borsapy as bp
 20        >>> screener = bp.Screener()
 21        >>> screener.add_filter("market_cap", min=1000)  # Min $1B market cap
 22        >>> screener.add_filter("dividend_yield", min=3)  # Min 3% dividend yield
 23        >>> results = screener.run()
 24           symbol                  name  market_cap  dividend_yield
 25        0   THYAO     Türk Hava Yolları      5234.5             4.2
 26        1   GARAN     Garanti Bankası       8123.4             5.1
 27        ...
 28
 29        >>> # Using templates
 30        >>> results = bp.screen_stocks(template="high_dividend")
 31
 32        >>> # Direct filtering
 33        >>> results = bp.screen_stocks(market_cap_min=1000, pe_max=15)
 34    """
 35
 36    # Available templates
 37    TEMPLATES = [
 38        "small_cap",
 39        "mid_cap",
 40        "large_cap",
 41        "high_dividend",
 42        "high_upside",
 43        "low_upside",
 44        "high_volume",
 45        "low_volume",
 46        "buy_recommendation",
 47        "sell_recommendation",
 48        "high_net_margin",
 49        "high_return",
 50        "low_pe",
 51        "high_roe",
 52        "high_foreign_ownership",
 53    ]
 54
 55    def __init__(self):
 56        """Initialize Screener."""
 57        self._provider = get_screener_provider()
 58        self._filters: list[tuple[str, str, str, str]] = []
 59        self._sector: str | None = None
 60        self._index: str | None = None
 61        self._recommendation: str | None = None
 62
 63    # Default min/max values for criteria when only one bound is specified
 64    # API requires both min and max - these are sensible defaults
 65    CRITERIA_DEFAULTS = {
 66        "price": {"min": 0, "max": 100000},
 67        "market_cap": {"min": 0, "max": 5000000},  # TL millions
 68        "market_cap_usd": {"min": 0, "max": 100000},  # USD millions
 69        "pe": {"min": -1000, "max": 10000},
 70        "pb": {"min": -100, "max": 1000},
 71        "ev_ebitda": {"min": -100, "max": 1000},
 72        "ev_sales": {"min": -100, "max": 1000},
 73        "dividend_yield": {"min": 0, "max": 100},
 74        "dividend_yield_2025": {"min": 0, "max": 100},
 75        "roe": {"min": -200, "max": 500},
 76        "roa": {"min": -200, "max": 500},
 77        "net_margin": {"min": -200, "max": 500},
 78        "ebitda_margin": {"min": -200, "max": 500},
 79        "upside_potential": {"min": -100, "max": 500},
 80        "foreign_ratio": {"min": 0, "max": 100},
 81        "float_ratio": {"min": 0, "max": 100},
 82        "return_1w": {"min": -100, "max": 100},
 83        "return_1m": {"min": -100, "max": 200},
 84        "return_1y": {"min": -100, "max": 1000},
 85        "return_ytd": {"min": -100, "max": 1000},
 86        "volume_3m": {"min": 0, "max": 1000},
 87        "volume_12m": {"min": 0, "max": 1000},  # 12 aylık ortalama hacim (mn $)
 88        "float_market_cap": {"min": 0, "max": 100000},  # Halka açık piyasa değeri (mn $)
 89    }
 90
 91    def add_filter(
 92        self,
 93        criteria: str,
 94        min: float | None = None,
 95        max: float | None = None,
 96        required: bool = False,
 97    ) -> "Screener":
 98        """
 99        Add a filter criterion.
100
101        Args:
102            criteria: Criteria name (market_cap, pe, dividend_yield, etc.).
103            min: Minimum value.
104            max: Maximum value.
105            required: Whether this filter is required.
106
107        Returns:
108            Self for method chaining.
109
110        Examples:
111            >>> screener = Screener()
112            >>> screener.add_filter("market_cap", min=1000)
113            >>> screener.add_filter("pe", max=15)
114        """
115        # Map criteria name to ID
116        criteria_map = self._provider.CRITERIA_MAP
117        criteria_id = criteria_map.get(criteria.lower(), criteria)
118
119        # Get default bounds for this criteria
120        defaults = self.CRITERIA_DEFAULTS.get(criteria.lower(), {"min": -999999, "max": 999999})
121
122        # API requires both min and max - use defaults when only one is provided
123        if min is None and max is not None:
124            min = defaults["min"]
125        elif max is None and min is not None:
126            max = defaults["max"]
127
128        min_str = str(min) if min is not None else ""
129        max_str = str(max) if max is not None else ""
130        required_str = "True" if required else "False"
131
132        self._filters.append((criteria_id, min_str, max_str, required_str))
133        return self
134
135    def set_sector(self, sector: str) -> "Screener":
136        """
137        Set sector filter.
138
139        Args:
140            sector: Sector name (e.g., "Bankacılık") or ID (e.g., "0001").
141
142        Returns:
143            Self for method chaining.
144        """
145        # Convert sector name to ID if needed
146        if sector and not sector.startswith("0"):
147            sectors_data = self._provider.get_sectors()
148            for s in sectors_data:
149                if s.get("name", "").lower() == sector.lower():
150                    sector = s.get("id", sector)
151                    break
152        self._sector = sector
153        return self
154
155    def set_index(self, index: str) -> "Screener":
156        """
157        Set index filter.
158
159        Args:
160            index: Index name (e.g., "BIST 30", "BIST 100").
161
162        Returns:
163            Self for method chaining.
164        """
165        # Note: Index filtering may have limited support in the API
166        self._index = index
167        return self
168
169    def set_recommendation(self, recommendation: str) -> "Screener":
170        """
171        Set recommendation filter.
172
173        Args:
174            recommendation: Recommendation type ("AL", "SAT", "TUT").
175
176        Returns:
177            Self for method chaining.
178        """
179        self._recommendation = recommendation.upper()
180        return self
181
182    def clear(self) -> "Screener":
183        """
184        Clear all filters.
185
186        Returns:
187            Self for method chaining.
188        """
189        self._filters = []
190        self._sector = None
191        self._index = None
192        self._recommendation = None
193        return self
194
195    def run(self, template: str | None = None) -> pd.DataFrame:
196        """
197        Run the screener and return results.
198
199        Args:
200            template: Optional pre-defined template to use.
201
202        Returns:
203            DataFrame with matching stocks.
204        """
205        # Note: İş Yatırım API doesn't support index filtering directly,
206        # so we filter locally using BIST index components
207        results = self._provider.screen(
208            criterias=self._filters if self._filters else None,
209            sector=self._sector,
210            index=None,  # API doesn't support this, we filter locally
211            recommendation=self._recommendation,
212            template=template,
213        )
214
215        if not results:
216            return pd.DataFrame(columns=["symbol", "name"])
217
218        df = pd.DataFrame(results)
219
220        # Filter by index if specified
221        if self._index and not df.empty:
222            df = self._filter_by_index(df, self._index)
223
224        return df
225
226    def _filter_by_index(self, df: pd.DataFrame, index: str) -> pd.DataFrame:
227        """Filter DataFrame to only include symbols in the specified index."""
228        # Normalize index name to code (e.g., "BIST 30" -> "XU030")
229        index_map = {
230            "BIST 30": "XU030",
231            "BIST30": "XU030",
232            "BIST 50": "XU050",
233            "BIST50": "XU050",
234            "BIST 100": "XU100",
235            "BIST100": "XU100",
236            "BIST BANKA": "XBANK",
237            "BIST SINAİ": "XUSIN",
238            "BIST HİZMETLER": "XUHIZ",
239            "BIST TEKNOLOJİ": "XUTEK",
240        }
241        index_code = index_map.get(index.upper().replace("_", " ").replace("-", " "), index.upper())
242
243        try:
244            provider = get_bist_index_provider()
245            components = provider.get_components(index_code)
246            if components:
247                symbols = [c["symbol"] for c in components]
248                return df[df["symbol"].isin(symbols)].reset_index(drop=True)
249        except Exception:
250            pass  # If index lookup fails, return unfiltered
251
252        return df
253
254    def __repr__(self) -> str:
255        return f"Screener(filters={len(self._filters)}, sector={self._sector}, index={self._index})"

A yfinance-like interface for BIST stock screening.

Data source: İş Yatırım

Examples:

import borsapy as bp screener = bp.Screener() screener.add_filter("market_cap", min=1000) # Min $1B market cap screener.add_filter("dividend_yield", min=3) # Min 3% dividend yield results = screener.run() symbol name market_cap dividend_yield 0 THYAO Türk Hava Yolları 5234.5 4.2 1 GARAN Garanti Bankası 8123.4 5.1 ...

>>> # Using templates
>>> results = bp.screen_stocks(template="high_dividend")

>>> # Direct filtering
>>> results = bp.screen_stocks(market_cap_min=1000, pe_max=15)
Screener()
55    def __init__(self):
56        """Initialize Screener."""
57        self._provider = get_screener_provider()
58        self._filters: list[tuple[str, str, str, str]] = []
59        self._sector: str | None = None
60        self._index: str | None = None
61        self._recommendation: str | None = None

Initialize Screener.

TEMPLATES = ['small_cap', 'mid_cap', 'large_cap', 'high_dividend', 'high_upside', 'low_upside', 'high_volume', 'low_volume', 'buy_recommendation', 'sell_recommendation', 'high_net_margin', 'high_return', 'low_pe', 'high_roe', 'high_foreign_ownership']
CRITERIA_DEFAULTS = {'price': {'min': 0, 'max': 100000}, 'market_cap': {'min': 0, 'max': 5000000}, 'market_cap_usd': {'min': 0, 'max': 100000}, 'pe': {'min': -1000, 'max': 10000}, 'pb': {'min': -100, 'max': 1000}, 'ev_ebitda': {'min': -100, 'max': 1000}, 'ev_sales': {'min': -100, 'max': 1000}, 'dividend_yield': {'min': 0, 'max': 100}, 'dividend_yield_2025': {'min': 0, 'max': 100}, 'roe': {'min': -200, 'max': 500}, 'roa': {'min': -200, 'max': 500}, 'net_margin': {'min': -200, 'max': 500}, 'ebitda_margin': {'min': -200, 'max': 500}, 'upside_potential': {'min': -100, 'max': 500}, 'foreign_ratio': {'min': 0, 'max': 100}, 'float_ratio': {'min': 0, 'max': 100}, 'return_1w': {'min': -100, 'max': 100}, 'return_1m': {'min': -100, 'max': 200}, 'return_1y': {'min': -100, 'max': 1000}, 'return_ytd': {'min': -100, 'max': 1000}, 'volume_3m': {'min': 0, 'max': 1000}, 'volume_12m': {'min': 0, 'max': 1000}, 'float_market_cap': {'min': 0, 'max': 100000}}
def add_filter( self, criteria: str, min: float | None = None, max: float | None = None, required: bool = False) -> Screener:
 91    def add_filter(
 92        self,
 93        criteria: str,
 94        min: float | None = None,
 95        max: float | None = None,
 96        required: bool = False,
 97    ) -> "Screener":
 98        """
 99        Add a filter criterion.
100
101        Args:
102            criteria: Criteria name (market_cap, pe, dividend_yield, etc.).
103            min: Minimum value.
104            max: Maximum value.
105            required: Whether this filter is required.
106
107        Returns:
108            Self for method chaining.
109
110        Examples:
111            >>> screener = Screener()
112            >>> screener.add_filter("market_cap", min=1000)
113            >>> screener.add_filter("pe", max=15)
114        """
115        # Map criteria name to ID
116        criteria_map = self._provider.CRITERIA_MAP
117        criteria_id = criteria_map.get(criteria.lower(), criteria)
118
119        # Get default bounds for this criteria
120        defaults = self.CRITERIA_DEFAULTS.get(criteria.lower(), {"min": -999999, "max": 999999})
121
122        # API requires both min and max - use defaults when only one is provided
123        if min is None and max is not None:
124            min = defaults["min"]
125        elif max is None and min is not None:
126            max = defaults["max"]
127
128        min_str = str(min) if min is not None else ""
129        max_str = str(max) if max is not None else ""
130        required_str = "True" if required else "False"
131
132        self._filters.append((criteria_id, min_str, max_str, required_str))
133        return self

Add a filter criterion.

Args: criteria: Criteria name (market_cap, pe, dividend_yield, etc.). min: Minimum value. max: Maximum value. required: Whether this filter is required.

Returns: Self for method chaining.

Examples:

screener = Screener() screener.add_filter("market_cap", min=1000) screener.add_filter("pe", max=15)

def set_sector(self, sector: str) -> Screener:
135    def set_sector(self, sector: str) -> "Screener":
136        """
137        Set sector filter.
138
139        Args:
140            sector: Sector name (e.g., "Bankacılık") or ID (e.g., "0001").
141
142        Returns:
143            Self for method chaining.
144        """
145        # Convert sector name to ID if needed
146        if sector and not sector.startswith("0"):
147            sectors_data = self._provider.get_sectors()
148            for s in sectors_data:
149                if s.get("name", "").lower() == sector.lower():
150                    sector = s.get("id", sector)
151                    break
152        self._sector = sector
153        return self

Set sector filter.

Args: sector: Sector name (e.g., "Bankacılık") or ID (e.g., "0001").

Returns: Self for method chaining.

def set_index(self, index: str) -> Screener:
155    def set_index(self, index: str) -> "Screener":
156        """
157        Set index filter.
158
159        Args:
160            index: Index name (e.g., "BIST 30", "BIST 100").
161
162        Returns:
163            Self for method chaining.
164        """
165        # Note: Index filtering may have limited support in the API
166        self._index = index
167        return self

Set index filter.

Args: index: Index name (e.g., "BIST 30", "BIST 100").

Returns: Self for method chaining.

def set_recommendation(self, recommendation: str) -> Screener:
169    def set_recommendation(self, recommendation: str) -> "Screener":
170        """
171        Set recommendation filter.
172
173        Args:
174            recommendation: Recommendation type ("AL", "SAT", "TUT").
175
176        Returns:
177            Self for method chaining.
178        """
179        self._recommendation = recommendation.upper()
180        return self

Set recommendation filter.

Args: recommendation: Recommendation type ("AL", "SAT", "TUT").

Returns: Self for method chaining.

def clear(self) -> Screener:
182    def clear(self) -> "Screener":
183        """
184        Clear all filters.
185
186        Returns:
187            Self for method chaining.
188        """
189        self._filters = []
190        self._sector = None
191        self._index = None
192        self._recommendation = None
193        return self

Clear all filters.

Returns: Self for method chaining.

def run(self, template: str | None = None) -> pandas.core.frame.DataFrame:
195    def run(self, template: str | None = None) -> pd.DataFrame:
196        """
197        Run the screener and return results.
198
199        Args:
200            template: Optional pre-defined template to use.
201
202        Returns:
203            DataFrame with matching stocks.
204        """
205        # Note: İş Yatırım API doesn't support index filtering directly,
206        # so we filter locally using BIST index components
207        results = self._provider.screen(
208            criterias=self._filters if self._filters else None,
209            sector=self._sector,
210            index=None,  # API doesn't support this, we filter locally
211            recommendation=self._recommendation,
212            template=template,
213        )
214
215        if not results:
216            return pd.DataFrame(columns=["symbol", "name"])
217
218        df = pd.DataFrame(results)
219
220        # Filter by index if specified
221        if self._index and not df.empty:
222            df = self._filter_by_index(df, self._index)
223
224        return df

Run the screener and return results.

Args: template: Optional pre-defined template to use.

Returns: DataFrame with matching stocks.

class TradingViewStream:
 782class TradingViewStream:
 783    """
 784    Persistent WebSocket connection for real-time TradingView data.
 785
 786    Optimized for:
 787    - Low latency (~50-100ms)
 788    - High throughput (10-20 updates/sec)
 789    - Multiple symbol subscriptions
 790    - Automatic reconnection
 791
 792    Attributes:
 793        is_connected: Whether the WebSocket is currently connected.
 794        subscribed_symbols: Set of currently subscribed symbols.
 795
 796    Examples:
 797        Basic usage::
 798
 799            stream = TradingViewStream()
 800            stream.connect()
 801            stream.subscribe("THYAO")
 802            quote = stream.get_quote("THYAO")
 803
 804        With callbacks::
 805
 806            def on_price_update(symbol, quote):
 807                print(f"{symbol}: {quote['last']}")
 808
 809            stream.on_quote("THYAO", on_price_update)
 810
 811        Context manager::
 812
 813            with TradingViewStream() as stream:
 814                stream.subscribe("THYAO")
 815                # Trading logic...
 816    """
 817
 818    WS_URL = "wss://data.tradingview.com/socket.io/websocket?type=chart"
 819    ORIGIN = "https://www.tradingview.com"
 820
 821    # Reconnection settings
 822    MAX_RECONNECT_ATTEMPTS = 10
 823    MAX_RECONNECT_DELAY = 30  # seconds
 824    HEARTBEAT_INTERVAL = 30  # seconds
 825
 826    def __init__(self, auth_token: str | None = None):
 827        """
 828        Initialize TradingViewStream.
 829
 830        Args:
 831            auth_token: Optional TradingView auth token for real-time data.
 832                        If not provided, uses unauthorized token (~15min delay).
 833        """
 834        self._ws: websocket.WebSocketApp | None = None
 835        self._ws_thread: threading.Thread | None = None
 836        self._heartbeat_thread: threading.Thread | None = None
 837
 838        # Connection state
 839        self._connected = threading.Event()
 840        self._should_reconnect = True
 841        self._reconnect_attempts = 0
 842        self._last_heartbeat_time = 0.0
 843        self._heartbeat_counter = 0
 844
 845        # Session management
 846        self._quote_session: str | None = None
 847        self._chart_session: str | None = None
 848        self._auth_token = auth_token
 849
 850        # Data storage (thread-safe)
 851        self._lock = threading.RLock()
 852        self._quotes: dict[str, dict[str, Any]] = {}  # symbol -> latest quote
 853        self._subscribed: set[str] = set()  # subscribed symbols
 854        self._pending_subscribes: set[str] = set()  # waiting for confirmation
 855
 856        # Chart session data
 857        # Format: {symbol: {interval: [candles]}}
 858        self._chart_data: dict[str, dict[str, list[dict]]] = {}
 859        self._chart_subscribed: dict[str, set[str]] = {}  # symbol -> set of intervals
 860        self._chart_series_counter = 0  # Counter for unique series IDs
 861        self._chart_series_map: dict[str, tuple[str, str]] = {}  # series_id -> (symbol, interval)
 862
 863        # Callbacks
 864        self._callbacks: dict[str, list[Callable[[str, dict], None]]] = {}
 865        self._global_callbacks: list[Callable[[str, dict], None]] = []
 866        # Chart callbacks: {f"{symbol}:{interval}": [callbacks]}
 867        self._chart_callbacks: dict[str, list[Callable[[str, str, dict], None]]] = {}
 868        self._global_chart_callbacks: list[Callable[[str, str, dict], None]] = []
 869
 870        # Events for synchronization
 871        self._quote_events: dict[str, threading.Event] = {}
 872        self._chart_events: dict[str, threading.Event] = {}  # f"{symbol}:{interval}" -> Event
 873
 874        # Pine Script studies session (lazy-loaded)
 875        self._study_session: StudySession | None = None
 876
 877    @property
 878    def is_connected(self) -> bool:
 879        """Check if WebSocket is connected."""
 880        return self._connected.is_set()
 881
 882    @property
 883    def subscribed_symbols(self) -> set[str]:
 884        """Get set of currently subscribed symbols."""
 885        with self._lock:
 886            return self._subscribed.copy()
 887
 888    def _get_auth_token(self) -> str:
 889        """Get auth token from credentials or use unauthorized."""
 890        if self._auth_token:
 891            return self._auth_token
 892        creds = get_tradingview_auth()
 893        if creds and creds.get("auth_token"):
 894            return creds["auth_token"]
 895        return "unauthorized_user_token"
 896
 897    def _generate_session_id(self, prefix: str = "qs") -> str:
 898        """Generate random session ID like qs_abc123xyz."""
 899        chars = string.ascii_lowercase + string.digits
 900        suffix = "".join(random.choice(chars) for _ in range(12))
 901        return f"{prefix}_{suffix}"
 902
 903    def _format_packet(self, data: str | dict) -> str:
 904        """Format data into TradingView packet format: ~m~{length}~m~{data}"""
 905        content = (
 906            json.dumps(data, separators=(",", ":"))
 907            if isinstance(data, dict)
 908            else data
 909        )
 910        return f"~m~{len(content)}~m~{content}"
 911
 912    def _create_message(self, method: str, params: list) -> str:
 913        """Create a TradingView message."""
 914        msg = json.dumps({"m": method, "p": params}, separators=(",", ":"))
 915        return self._format_packet(msg)
 916
 917    def _parse_packets(self, raw: str) -> list[dict | str]:
 918        """
 919        Parse TradingView packets from raw WebSocket message.
 920
 921        Handles both JSON packets and heartbeat packets (~h~{number}).
 922        """
 923        packets: list[dict | str] = []
 924
 925        # Find all packets using regex
 926        # Pattern: ~m~{length}~m~{content} or ~h~{number}
 927        pattern = r"~m~(\d+)~m~|~h~(\d+)"
 928
 929        for match in re.finditer(pattern, raw):
 930            if match.group(2):  # Heartbeat
 931                packets.append(f"~h~{match.group(2)}")
 932            elif match.group(1):  # Data packet
 933                length = int(match.group(1))
 934                start = match.end()
 935                content = raw[start : start + length]
 936                try:
 937                    packets.append(json.loads(content))
 938                except json.JSONDecodeError:
 939                    logger.warning(f"Failed to parse packet: {content[:100]}")
 940
 941        return packets
 942
 943    def _send(self, message: str) -> bool:
 944        """Send message to WebSocket (thread-safe)."""
 945        if self._ws and self.is_connected:
 946            try:
 947                self._ws.send(message)
 948                return True
 949            except Exception as e:
 950                logger.error(f"Send error: {e}")
 951                return False
 952        return False
 953
 954    def _on_open(self, ws: websocket.WebSocketApp) -> None:
 955        """Handle WebSocket connection opened."""
 956        logger.info("WebSocket connected")
 957
 958        # Reset reconnection counter
 959        self._reconnect_attempts = 0
 960
 961        # Generate new sessions
 962        self._quote_session = self._generate_session_id("qs")
 963        self._chart_session = self._generate_session_id("cs")
 964
 965        # 1. Set auth token
 966        auth_token = self._get_auth_token()
 967        ws.send(self._create_message("set_auth_token", [auth_token]))
 968
 969        # 2. Create quote session
 970        ws.send(self._create_message("quote_create_session", [self._quote_session]))
 971
 972        # 3. Set quote fields
 973        ws.send(
 974            self._create_message("quote_set_fields", [self._quote_session, *QUOTE_FIELDS])
 975        )
 976
 977        # 4. Create chart session
 978        ws.send(self._create_message("chart_create_session", [self._chart_session]))
 979
 980        # Mark as connected
 981        self._connected.set()
 982
 983        # Re-subscribe to existing symbols (for reconnection)
 984        with self._lock:
 985            for symbol in self._subscribed:
 986                self._send_subscribe(symbol)
 987
 988            # Re-subscribe to chart data
 989            for symbol, intervals in self._chart_subscribed.items():
 990                for interval in intervals:
 991                    self._send_chart_subscribe(symbol, interval)
 992
 993    def _on_message(self, ws: websocket.WebSocketApp, message: str) -> None:
 994        """Handle incoming WebSocket message."""
 995        packets = self._parse_packets(message)
 996
 997        for packet in packets:
 998            # Handle heartbeat
 999            if isinstance(packet, str) and packet.startswith("~h~"):
1000                self._handle_heartbeat(packet)
1001                continue
1002
1003            if not isinstance(packet, dict):
1004                continue
1005
1006            method = packet.get("m")
1007            params = packet.get("p", [])
1008
1009            if method == "qsd":
1010                # Quote data update
1011                self._handle_quote_data(params)
1012
1013            elif method == "quote_completed":
1014                # Initial quote load complete
1015                if len(params) >= 2:
1016                    symbol = params[1]
1017                    logger.debug(f"Quote completed for {symbol}")
1018                    # Signal waiting threads
1019                    if symbol in self._quote_events:
1020                        self._quote_events[symbol].set()
1021
1022            elif method == "critical_error":
1023                logger.error(f"TradingView critical error: {params}")
1024
1025            elif method == "symbol_error":
1026                logger.warning(f"Symbol error: {params}")
1027
1028            # Chart session messages
1029            elif method == "symbol_resolved":
1030                # Symbol successfully resolved for chart
1031                self._handle_symbol_resolved(params)
1032
1033            elif method in ("timescale_update", "du"):
1034                # OHLCV data update
1035                self._handle_chart_data(params)
1036
1037            elif method == "series_error":
1038                logger.warning(f"Chart series error: {params}")
1039
1040            elif method == "series_completed":
1041                # Chart series load complete
1042                if len(params) >= 2:
1043                    session_id = params[0]
1044                    logger.debug(f"Series completed for session {session_id}")
1045
1046            # Pine Script study messages
1047            elif method == "study_loading":
1048                # Study is loading
1049                if len(params) >= 2:
1050                    study_id = params[1]
1051                    if self._study_session:
1052                        self._study_session.handle_study_loading(study_id)
1053
1054            elif method == "study_completed":
1055                # Study finished loading
1056                if len(params) >= 2:
1057                    study_id = params[1]
1058                    if self._study_session:
1059                        self._study_session.handle_study_completed(study_id)
1060
1061            elif method == "study_error":
1062                # Study error
1063                if len(params) >= 3:
1064                    study_id = params[1]
1065                    error = params[2] if len(params) > 2 else "Unknown error"
1066                    if self._study_session:
1067                        self._study_session.handle_study_error(study_id, str(error))
1068
1069    def _handle_heartbeat(self, packet: str) -> None:
1070        """Handle heartbeat packet by echoing it back."""
1071        self._last_heartbeat_time = time.time()
1072        # Echo heartbeat back to server
1073        self._send(self._format_packet(packet))
1074        logger.debug(f"Heartbeat: {packet}")
1075
1076    def _handle_quote_data(self, params: list) -> None:
1077        """Handle quote data (qsd) packet."""
1078        if len(params) < 2 or not isinstance(params[1], dict):
1079            return
1080
1081        data = params[1]
1082        symbol = data.get("n", "")  # Full symbol like "BIST:THYAO"
1083        status = data.get("s")  # "ok" or "error"
1084        values = data.get("v", {})
1085
1086        if status != "ok" or not values:
1087            if status == "error":
1088                logger.warning(f"Quote error for {symbol}: {data}")
1089            return
1090
1091        # Extract base symbol (remove exchange prefix)
1092        base_symbol = symbol.split(":")[-1] if ":" in symbol else symbol
1093
1094        # Update quote cache
1095        with self._lock:
1096            if base_symbol not in self._quotes:
1097                self._quotes[base_symbol] = {}
1098            self._quotes[base_symbol].update(values)
1099            self._quotes[base_symbol]["_symbol"] = base_symbol
1100            self._quotes[base_symbol]["_full_symbol"] = symbol
1101            self._quotes[base_symbol]["_updated"] = time.time()
1102
1103        # Fire callbacks
1104        quote = self._build_quote(base_symbol)
1105
1106        # Symbol-specific callbacks
1107        for callback in self._callbacks.get(base_symbol, []):
1108            try:
1109                callback(base_symbol, quote)
1110            except Exception as e:
1111                logger.error(f"Callback error for {base_symbol}: {e}")
1112
1113        # Global callbacks
1114        for callback in self._global_callbacks:
1115            try:
1116                callback(base_symbol, quote)
1117            except Exception as e:
1118                logger.error(f"Global callback error: {e}")
1119
1120        # Signal waiting threads
1121        if base_symbol in self._quote_events:
1122            self._quote_events[base_symbol].set()
1123
1124    def _build_quote(self, symbol: str) -> dict[str, Any]:
1125        """Build standardized quote dict from raw data."""
1126        with self._lock:
1127            raw = self._quotes.get(symbol, {})
1128
1129        return {
1130            "symbol": symbol,
1131            "exchange": raw.get("exchange", "BIST"),
1132            "last": raw.get("lp"),
1133            "change": raw.get("ch"),
1134            "change_percent": raw.get("chp"),
1135            "open": raw.get("open_price"),
1136            "high": raw.get("high_price"),
1137            "low": raw.get("low_price"),
1138            "prev_close": raw.get("prev_close_price"),
1139            "volume": raw.get("volume"),
1140            "bid": raw.get("bid"),
1141            "ask": raw.get("ask"),
1142            "bid_size": raw.get("bid_size"),
1143            "ask_size": raw.get("ask_size"),
1144            "timestamp": raw.get("lp_time"),
1145            "description": raw.get("description"),
1146            "currency": raw.get("currency_code"),
1147            # Fundamentals
1148            "market_cap": raw.get("market_cap_basic"),
1149            "pe_ratio": raw.get("price_earnings_ttm"),
1150            "eps": raw.get("earnings_per_share_basic_ttm"),
1151            "dividend_yield": raw.get("dividends_yield"),
1152            "beta": raw.get("beta_1_year"),
1153            # 52 week
1154            "high_52_week": raw.get("high_52_week"),
1155            "low_52_week": raw.get("low_52_week"),
1156            # Meta
1157            "_updated": raw.get("_updated"),
1158            "_raw": raw,
1159        }
1160
1161    def _handle_symbol_resolved(self, params: list) -> None:
1162        """Handle symbol_resolved message for chart session."""
1163        if len(params) < 2:
1164            return
1165        # params[0] = session_id, params[1] = symbol_info dict
1166        logger.debug(f"Symbol resolved: {params}")
1167
1168    def _handle_chart_data(self, params: list) -> None:
1169        """Handle timescale_update or du (data update) messages."""
1170        if len(params) < 2:
1171            return
1172
1173        session_id = params[0]
1174        data = params[1]
1175
1176        if not isinstance(data, dict):
1177            return
1178
1179        # Process each series in the response
1180        for series_key, series_data in data.items():
1181            # Handle Pine Script study data (keys like "st1", "st2", etc.)
1182            if series_key.startswith("st") and self._study_session:
1183                self._study_session.handle_study_data(series_key, series_data)
1184                continue
1185
1186            if not series_key.startswith("$prices") and series_key != "s":
1187                # Look up the series mapping
1188                if series_key not in self._chart_series_map:
1189                    continue
1190
1191            # Get symbol and interval from series map or use default
1192            symbol = None
1193            interval = None
1194
1195            # Try to find series info
1196            for key, (sym, intv) in self._chart_series_map.items():
1197                if key in str(session_id) or series_key == "$prices":
1198                    symbol = sym
1199                    interval = intv
1200                    break
1201
1202            if not symbol or not interval:
1203                # Try to extract from the first available mapping
1204                if self._chart_series_map:
1205                    first_key = next(iter(self._chart_series_map))
1206                    symbol, interval = self._chart_series_map[first_key]
1207                else:
1208                    continue
1209
1210            # Extract candle data
1211            candles = []
1212            if isinstance(series_data, dict):
1213                bars = series_data.get("s", series_data.get("st", []))
1214                if isinstance(bars, list):
1215                    for bar in bars:
1216                        if isinstance(bar, dict) and "v" in bar:
1217                            v = bar["v"]
1218                            if len(v) >= 6:
1219                                candle = {
1220                                    "time": int(v[0]),
1221                                    "open": float(v[1]),
1222                                    "high": float(v[2]),
1223                                    "low": float(v[3]),
1224                                    "close": float(v[4]),
1225                                    "volume": float(v[5]) if v[5] else 0,
1226                                }
1227                                candles.append(candle)
1228
1229            if candles:
1230                self._update_chart_data(symbol, interval, candles)
1231
1232    def _update_chart_data(
1233        self, symbol: str, interval: str, candles: list[dict]
1234    ) -> None:
1235        """Update chart data cache and fire callbacks."""
1236        with self._lock:
1237            if symbol not in self._chart_data:
1238                self._chart_data[symbol] = {}
1239            if interval not in self._chart_data[symbol]:
1240                self._chart_data[symbol][interval] = []
1241
1242            # Update or append candles
1243            existing = self._chart_data[symbol][interval]
1244            for candle in candles:
1245                # Check if we need to update the last candle or add new
1246                if existing and existing[-1]["time"] == candle["time"]:
1247                    existing[-1] = candle
1248                elif not existing or candle["time"] > existing[-1]["time"]:
1249                    existing.append(candle)
1250
1251        # Fire callbacks
1252        callback_key = f"{symbol}:{interval}"
1253        latest_candle = candles[-1] if candles else None
1254
1255        if latest_candle:
1256            # Symbol-specific callbacks
1257            for callback in self._chart_callbacks.get(callback_key, []):
1258                try:
1259                    callback(symbol, interval, latest_candle)
1260                except Exception as e:
1261                    logger.error(f"Chart callback error for {callback_key}: {e}")
1262
1263            # Global chart callbacks
1264            for callback in self._global_chart_callbacks:
1265                try:
1266                    callback(symbol, interval, latest_candle)
1267                except Exception as e:
1268                    logger.error(f"Global chart callback error: {e}")
1269
1270            # Signal waiting threads
1271            if callback_key in self._chart_events:
1272                self._chart_events[callback_key].set()
1273
1274    def _send_chart_subscribe(self, symbol: str, interval: str, exchange: str = "BIST") -> None:
1275        """Send chart subscription messages."""
1276        tv_interval = CHART_TIMEFRAMES.get(interval, interval)
1277        tv_symbol = f"{exchange}:{symbol}"
1278
1279        # Generate unique series ID
1280        self._chart_series_counter += 1
1281        series_id = f"ser_{self._chart_series_counter}"
1282
1283        # Store mapping
1284        self._chart_series_map[series_id] = (symbol, interval)
1285
1286        # 1. Resolve symbol with configuration
1287        symbol_config = json.dumps({
1288            "symbol": tv_symbol,
1289            "adjustment": "splits",
1290            "session": "regular",
1291        })
1292        self._send(
1293            self._create_message(
1294                "resolve_symbol",
1295                [self._chart_session, series_id, f"={symbol_config}"],
1296            )
1297        )
1298
1299        # 2. Create series for OHLCV data
1300        self._send(
1301            self._create_message(
1302                "create_series",
1303                [
1304                    self._chart_session,
1305                    "$prices",  # Fixed price stream ID
1306                    "s1",  # Series index
1307                    series_id,  # Reference to resolved symbol
1308                    tv_interval,  # Timeframe
1309                    300,  # Number of bars
1310                ],
1311            )
1312        )
1313
1314    def _send_chart_unsubscribe(self, symbol: str, interval: str) -> None:
1315        """Send chart unsubscription messages."""
1316        # Find and remove series
1317        series_to_remove = None
1318        for series_id, (sym, intv) in list(self._chart_series_map.items()):
1319            if sym == symbol and intv == interval:
1320                series_to_remove = series_id
1321                break
1322
1323        if series_to_remove:
1324            self._send(
1325                self._create_message(
1326                    "remove_series", [self._chart_session, "$prices"]
1327                )
1328            )
1329            del self._chart_series_map[series_to_remove]
1330
1331    def _on_error(self, ws: websocket.WebSocketApp, error: Exception) -> None:
1332        """Handle WebSocket error."""
1333        logger.error(f"WebSocket error: {error}")
1334
1335    def _on_close(
1336        self,
1337        ws: websocket.WebSocketApp,
1338        close_status: int | None,
1339        close_msg: str | None,
1340    ) -> None:
1341        """Handle WebSocket close."""
1342        logger.info(f"WebSocket closed: {close_status} - {close_msg}")
1343        self._connected.clear()
1344
1345        # Attempt reconnection if needed
1346        if self._should_reconnect:
1347            self._reconnect()
1348
1349    def _reconnect(self) -> None:
1350        """Reconnect with exponential backoff."""
1351        if self._reconnect_attempts >= self.MAX_RECONNECT_ATTEMPTS:
1352            logger.error("Max reconnection attempts reached")
1353            return
1354
1355        # Calculate delay with exponential backoff
1356        delay = min(self.MAX_RECONNECT_DELAY, 2 ** self._reconnect_attempts)
1357        logger.info(f"Reconnecting in {delay}s (attempt {self._reconnect_attempts + 1})")
1358
1359        self._reconnect_attempts += 1
1360        time.sleep(delay)
1361
1362        # Reconnect
1363        self._start_websocket()
1364
1365    def _start_websocket(self) -> None:
1366        """Start WebSocket connection in background thread."""
1367        self._ws = websocket.WebSocketApp(
1368            f"{self.WS_URL}?type=chart",
1369            on_open=self._on_open,
1370            on_message=self._on_message,
1371            on_error=self._on_error,
1372            on_close=self._on_close,
1373            header={"Origin": self.ORIGIN},
1374        )
1375
1376        self._ws_thread = threading.Thread(
1377            target=self._ws.run_forever,
1378            kwargs={"ping_interval": 0},  # We handle heartbeat ourselves
1379            daemon=True,
1380        )
1381        self._ws_thread.start()
1382
1383    def _send_subscribe(self, symbol: str, exchange: str = "BIST") -> None:
1384        """Send subscribe message for symbol."""
1385        tv_symbol = f"{exchange}:{symbol}"
1386        self._send(
1387            self._create_message(
1388                "quote_add_symbols", [self._quote_session, tv_symbol]
1389            )
1390        )
1391
1392    def _send_unsubscribe(self, symbol: str, exchange: str = "BIST") -> None:
1393        """Send unsubscribe message for symbol."""
1394        tv_symbol = f"{exchange}:{symbol}"
1395        self._send(
1396            self._create_message(
1397                "quote_remove_symbols", [self._quote_session, tv_symbol]
1398            )
1399        )
1400
1401    # Public API
1402
1403    def connect(self, timeout: float = 10.0) -> bool:
1404        """
1405        Establish persistent WebSocket connection.
1406
1407        Args:
1408            timeout: Maximum time to wait for connection in seconds.
1409
1410        Returns:
1411            True if connected successfully, False otherwise.
1412
1413        Raises:
1414            TimeoutError: If connection times out.
1415
1416        Example:
1417            >>> stream = TradingViewStream()
1418            >>> stream.connect()
1419            >>> print(stream.is_connected)
1420            True
1421        """
1422        if self.is_connected:
1423            return True
1424
1425        self._should_reconnect = True
1426        self._start_websocket()
1427
1428        # Wait for connection
1429        if not self._connected.wait(timeout=timeout):
1430            raise TimeoutError(f"Connection timed out after {timeout}s")
1431
1432        return True
1433
1434    def disconnect(self) -> None:
1435        """
1436        Close WebSocket connection and cleanup.
1437
1438        Example:
1439            >>> stream.disconnect()
1440            >>> print(stream.is_connected)
1441            False
1442        """
1443        self._should_reconnect = False
1444        self._connected.clear()
1445
1446        if self._ws:
1447            self._ws.close()
1448            self._ws = None
1449
1450        if self._ws_thread and self._ws_thread.is_alive():
1451            self._ws_thread.join(timeout=2)
1452
1453        # Clear state
1454        with self._lock:
1455            # Quote state
1456            self._quotes.clear()
1457            self._subscribed.clear()
1458            self._callbacks.clear()
1459            self._global_callbacks.clear()
1460            self._quote_events.clear()
1461
1462            # Chart state
1463            self._chart_data.clear()
1464            self._chart_subscribed.clear()
1465            self._chart_series_map.clear()
1466            self._chart_callbacks.clear()
1467            self._global_chart_callbacks.clear()
1468            self._chart_events.clear()
1469
1470        logger.info("Disconnected")
1471
1472    def subscribe(self, symbol: str, exchange: str = "BIST") -> None:
1473        """
1474        Subscribe to symbol updates.
1475
1476        Args:
1477            symbol: Stock symbol (e.g., "THYAO", "GARAN")
1478            exchange: Exchange name (default: "BIST")
1479
1480        Example:
1481            >>> stream.subscribe("THYAO")
1482            >>> stream.subscribe("GARAN")
1483        """
1484        symbol = symbol.upper()
1485
1486        with self._lock:
1487            if symbol in self._subscribed:
1488                return
1489            self._subscribed.add(symbol)
1490            self._quote_events[symbol] = threading.Event()
1491
1492        if self.is_connected:
1493            self._send_subscribe(symbol, exchange)
1494
1495    def unsubscribe(self, symbol: str, exchange: str = "BIST") -> None:
1496        """
1497        Unsubscribe from symbol.
1498
1499        Args:
1500            symbol: Stock symbol
1501            exchange: Exchange name
1502
1503        Example:
1504            >>> stream.unsubscribe("THYAO")
1505        """
1506        symbol = symbol.upper()
1507
1508        with self._lock:
1509            self._subscribed.discard(symbol)
1510            self._quotes.pop(symbol, None)
1511            self._callbacks.pop(symbol, None)
1512            self._quote_events.pop(symbol, None)
1513
1514        if self.is_connected:
1515            self._send_unsubscribe(symbol, exchange)
1516
1517    # Chart Session API
1518
1519    def subscribe_chart(
1520        self, symbol: str, interval: str = "1m", exchange: str = "BIST"
1521    ) -> None:
1522        """
1523        Subscribe to OHLCV candle updates for a symbol.
1524
1525        Args:
1526            symbol: Stock symbol (e.g., "THYAO", "GARAN")
1527            interval: Candle interval. Valid values:
1528                      1m, 5m, 15m, 30m, 1h, 2h, 4h, 1d, 1wk, 1mo
1529            exchange: Exchange name (default: "BIST")
1530
1531        Example:
1532            >>> stream.subscribe_chart("THYAO", "1m")
1533            >>> stream.subscribe_chart("GARAN", "1h")
1534        """
1535        symbol = symbol.upper()
1536        interval = interval.lower()
1537
1538        # Validate interval
1539        if interval not in CHART_TIMEFRAMES:
1540            raise ValueError(
1541                f"Invalid interval '{interval}'. "
1542                f"Valid intervals: {list(CHART_TIMEFRAMES.keys())}"
1543            )
1544
1545        with self._lock:
1546            if symbol not in self._chart_subscribed:
1547                self._chart_subscribed[symbol] = set()
1548
1549            if interval in self._chart_subscribed[symbol]:
1550                return  # Already subscribed
1551
1552            self._chart_subscribed[symbol].add(interval)
1553
1554            # Initialize data storage
1555            if symbol not in self._chart_data:
1556                self._chart_data[symbol] = {}
1557            if interval not in self._chart_data[symbol]:
1558                self._chart_data[symbol][interval] = []
1559
1560            # Create event for this subscription
1561            event_key = f"{symbol}:{interval}"
1562            self._chart_events[event_key] = threading.Event()
1563
1564        if self.is_connected:
1565            self._send_chart_subscribe(symbol, interval, exchange)
1566
1567    def unsubscribe_chart(
1568        self, symbol: str, interval: str, exchange: str = "BIST"
1569    ) -> None:
1570        """
1571        Unsubscribe from chart updates.
1572
1573        Args:
1574            symbol: Stock symbol
1575            interval: Candle interval
1576            exchange: Exchange name
1577
1578        Example:
1579            >>> stream.unsubscribe_chart("THYAO", "1m")
1580        """
1581        symbol = symbol.upper()
1582        interval = interval.lower()
1583
1584        with self._lock:
1585            if symbol in self._chart_subscribed:
1586                self._chart_subscribed[symbol].discard(interval)
1587                if not self._chart_subscribed[symbol]:
1588                    del self._chart_subscribed[symbol]
1589
1590            if symbol in self._chart_data and interval in self._chart_data[symbol]:
1591                del self._chart_data[symbol][interval]
1592                if not self._chart_data[symbol]:
1593                    del self._chart_data[symbol]
1594
1595            event_key = f"{symbol}:{interval}"
1596            self._chart_events.pop(event_key, None)
1597            self._chart_callbacks.pop(event_key, None)
1598
1599        if self.is_connected:
1600            self._send_chart_unsubscribe(symbol, interval)
1601
1602    def get_candle(self, symbol: str, interval: str) -> dict[str, Any] | None:
1603        """
1604        Get latest cached candle (instant, ~1ms).
1605
1606        Args:
1607            symbol: Stock symbol
1608            interval: Candle interval
1609
1610        Returns:
1611            Candle dict or None if not subscribed/no data yet.
1612            Candle format:
1613            {
1614                "time": 1737123456,      # Unix timestamp
1615                "open": 285.0,           # Open price
1616                "high": 286.5,           # High price
1617                "low": 284.0,            # Low price
1618                "close": 285.5,          # Close price
1619                "volume": 123456         # Volume
1620            }
1621
1622        Example:
1623            >>> candle = stream.get_candle("THYAO", "1m")
1624            >>> print(candle['close'])
1625            285.5
1626        """
1627        symbol = symbol.upper()
1628        interval = interval.lower()
1629
1630        with self._lock:
1631            if symbol not in self._chart_data:
1632                return None
1633            if interval not in self._chart_data[symbol]:
1634                return None
1635            candles = self._chart_data[symbol][interval]
1636            if not candles:
1637                return None
1638            return candles[-1].copy()
1639
1640    def get_candles(
1641        self, symbol: str, interval: str, count: int | None = None
1642    ) -> list[dict[str, Any]]:
1643        """
1644        Get cached candles.
1645
1646        Args:
1647            symbol: Stock symbol
1648            interval: Candle interval
1649            count: Number of candles to return (None = all)
1650
1651        Returns:
1652            List of candle dicts, oldest first.
1653
1654        Example:
1655            >>> candles = stream.get_candles("THYAO", "1m", count=10)
1656            >>> print(len(candles))
1657            10
1658        """
1659        symbol = symbol.upper()
1660        interval = interval.lower()
1661
1662        with self._lock:
1663            if symbol not in self._chart_data:
1664                return []
1665            if interval not in self._chart_data[symbol]:
1666                return []
1667            candles = self._chart_data[symbol][interval]
1668            if count:
1669                return [c.copy() for c in candles[-count:]]
1670            return [c.copy() for c in candles]
1671
1672    def wait_for_candle(
1673        self, symbol: str, interval: str, timeout: float = 5.0
1674    ) -> dict[str, Any]:
1675        """
1676        Wait for first candle (blocking).
1677
1678        Useful after subscribing to ensure data is received.
1679
1680        Args:
1681            symbol: Stock symbol
1682            interval: Candle interval
1683            timeout: Maximum wait time in seconds
1684
1685        Returns:
1686            Candle dict
1687
1688        Raises:
1689            TimeoutError: If candle not received within timeout.
1690
1691        Example:
1692            >>> stream.subscribe_chart("THYAO", "1m")
1693            >>> candle = stream.wait_for_candle("THYAO", "1m")
1694        """
1695        symbol = symbol.upper()
1696        interval = interval.lower()
1697
1698        # Check if already have data
1699        candle = self.get_candle(symbol, interval)
1700        if candle:
1701            return candle
1702
1703        # Wait for data
1704        event_key = f"{symbol}:{interval}"
1705        with self._lock:
1706            if event_key not in self._chart_events:
1707                self._chart_events[event_key] = threading.Event()
1708            event = self._chart_events[event_key]
1709            event.clear()
1710
1711        if not event.wait(timeout=timeout):
1712            raise TimeoutError(f"Timeout waiting for candle: {symbol} {interval}")
1713
1714        candle = self.get_candle(symbol, interval)
1715        if candle is None:
1716            raise TimeoutError(f"No candle data received for {symbol} {interval}")
1717        return candle
1718
1719    def on_candle(
1720        self,
1721        symbol: str,
1722        interval: str,
1723        callback: Callable[[str, str, dict], None],
1724    ) -> None:
1725        """
1726        Register callback for candle updates.
1727
1728        Callback signature: callback(symbol: str, interval: str, candle: dict)
1729
1730        Args:
1731            symbol: Stock symbol
1732            interval: Candle interval
1733            callback: Function to call on each update
1734
1735        Example:
1736            >>> def on_candle_update(symbol, interval, candle):
1737            ...     print(f"{symbol} {interval}: O={candle['open']} C={candle['close']}")
1738            >>> stream.on_candle("THYAO", "1m", on_candle_update)
1739        """
1740        symbol = symbol.upper()
1741        interval = interval.lower()
1742        callback_key = f"{symbol}:{interval}"
1743
1744        with self._lock:
1745            if callback_key not in self._chart_callbacks:
1746                self._chart_callbacks[callback_key] = []
1747            self._chart_callbacks[callback_key].append(callback)
1748
1749    def on_any_candle(
1750        self, callback: Callable[[str, str, dict], None]
1751    ) -> None:
1752        """
1753        Register callback for all candle updates.
1754
1755        Args:
1756            callback: Function to call on each update for any subscription.
1757
1758        Example:
1759            >>> def on_any_update(symbol, interval, candle):
1760            ...     print(f"{symbol} {interval}: {candle['close']}")
1761            >>> stream.on_any_candle(on_any_update)
1762        """
1763        self._global_chart_callbacks.append(callback)
1764
1765    def remove_candle_callback(
1766        self,
1767        symbol: str,
1768        interval: str,
1769        callback: Callable[[str, str, dict], None],
1770    ) -> None:
1771        """
1772        Remove a registered candle callback.
1773
1774        Args:
1775            symbol: Stock symbol
1776            interval: Candle interval
1777            callback: The callback to remove
1778        """
1779        symbol = symbol.upper()
1780        interval = interval.lower()
1781        callback_key = f"{symbol}:{interval}"
1782
1783        with self._lock:
1784            if callback_key in self._chart_callbacks:
1785                try:
1786                    self._chart_callbacks[callback_key].remove(callback)
1787                except ValueError:
1788                    pass
1789
1790    @property
1791    def chart_subscriptions(self) -> dict[str, set[str]]:
1792        """Get current chart subscriptions.
1793
1794        Returns:
1795            Dict mapping symbol to set of subscribed intervals.
1796        """
1797        with self._lock:
1798            return {
1799                sym: intervals.copy()
1800                for sym, intervals in self._chart_subscribed.items()
1801            }
1802
1803    def get_quote(self, symbol: str) -> dict[str, Any] | None:
1804        """
1805        Get latest cached quote (instant, ~1ms).
1806
1807        Args:
1808            symbol: Stock symbol
1809
1810        Returns:
1811            Quote dict or None if not subscribed/no data yet.
1812
1813        Example:
1814            >>> quote = stream.get_quote("THYAO")
1815            >>> print(quote['last'])
1816            299.0
1817        """
1818        symbol = symbol.upper()
1819        with self._lock:
1820            if symbol not in self._quotes:
1821                return None
1822        return self._build_quote(symbol)
1823
1824    def wait_for_quote(
1825        self, symbol: str, timeout: float = 5.0
1826    ) -> dict[str, Any]:
1827        """
1828        Wait for first quote (blocking).
1829
1830        Useful after subscribing to ensure data is received.
1831
1832        Args:
1833            symbol: Stock symbol
1834            timeout: Maximum wait time in seconds
1835
1836        Returns:
1837            Quote dict
1838
1839        Raises:
1840            TimeoutError: If quote not received within timeout.
1841
1842        Example:
1843            >>> stream.subscribe("THYAO")
1844            >>> quote = stream.wait_for_quote("THYAO")
1845        """
1846        symbol = symbol.upper()
1847
1848        # Check if already have data
1849        quote = self.get_quote(symbol)
1850        if quote and quote.get("last") is not None:
1851            return quote
1852
1853        # Wait for data
1854        with self._lock:
1855            if symbol not in self._quote_events:
1856                self._quote_events[symbol] = threading.Event()
1857            event = self._quote_events[symbol]
1858            event.clear()
1859
1860        if not event.wait(timeout=timeout):
1861            raise TimeoutError(f"Timeout waiting for quote: {symbol}")
1862
1863        quote = self.get_quote(symbol)
1864        if quote is None:
1865            raise TimeoutError(f"No quote data received for {symbol}")
1866        return quote
1867
1868    def on_quote(
1869        self, symbol: str, callback: Callable[[str, dict], None]
1870    ) -> None:
1871        """
1872        Register callback for quote updates.
1873
1874        Callback signature: callback(symbol: str, quote: dict)
1875
1876        Args:
1877            symbol: Stock symbol
1878            callback: Function to call on each update
1879
1880        Example:
1881            >>> def on_price_update(symbol, quote):
1882            ...     print(f"{symbol}: {quote['last']}")
1883            >>> stream.on_quote("THYAO", on_price_update)
1884        """
1885        symbol = symbol.upper()
1886        with self._lock:
1887            if symbol not in self._callbacks:
1888                self._callbacks[symbol] = []
1889            self._callbacks[symbol].append(callback)
1890
1891    def on_any_quote(self, callback: Callable[[str, dict], None]) -> None:
1892        """
1893        Register callback for all quote updates.
1894
1895        Args:
1896            callback: Function to call on each update for any symbol.
1897
1898        Example:
1899            >>> def on_any_update(symbol, quote):
1900            ...     print(f"{symbol}: {quote['last']}")
1901            >>> stream.on_any_quote(on_any_update)
1902        """
1903        self._global_callbacks.append(callback)
1904
1905    def remove_callback(
1906        self, symbol: str, callback: Callable[[str, dict], None]
1907    ) -> None:
1908        """
1909        Remove a registered callback.
1910
1911        Args:
1912            symbol: Stock symbol
1913            callback: The callback to remove
1914        """
1915        symbol = symbol.upper()
1916        with self._lock:
1917            if symbol in self._callbacks:
1918                try:
1919                    self._callbacks[symbol].remove(callback)
1920                except ValueError:
1921                    pass
1922
1923    def wait(self) -> None:
1924        """
1925        Block until disconnect.
1926
1927        Useful for keeping the stream alive in main thread.
1928
1929        Example:
1930            >>> stream.connect()
1931            >>> stream.subscribe("THYAO")
1932            >>> stream.on_quote("THYAO", my_callback)
1933            >>> stream.wait()  # Blocks forever
1934        """
1935        if self._ws_thread:
1936            self._ws_thread.join()
1937
1938    # Context manager
1939
1940    def __enter__(self) -> TradingViewStream:
1941        """Context manager entry."""
1942        self.connect()
1943        return self
1944
1945    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
1946        """Context manager exit."""
1947        self.disconnect()
1948
1949    # Pine Script Study API
1950
1951    @property
1952    def studies(self) -> StudySession:
1953        """
1954        Access Pine Script study session.
1955
1956        Returns:
1957            StudySession instance for managing indicators.
1958
1959        Example:
1960            >>> stream.studies.add("THYAO", "1m", "RSI")
1961            >>> stream.studies.get("THYAO", "1m", "RSI")
1962        """
1963        if self._study_session is None:
1964            self._study_session = StudySession(self)
1965        return self._study_session
1966
1967    def add_study(
1968        self,
1969        symbol: str,
1970        interval: str,
1971        indicator: str,
1972        **kwargs: Any,
1973    ) -> str:
1974        """
1975        Add a Pine indicator study (convenience method).
1976
1977        Args:
1978            symbol: Stock symbol (e.g., "THYAO")
1979            interval: Chart interval (e.g., "1m", "1d")
1980            indicator: Indicator name. Examples:
1981                       - "RSI", "MACD", "BB" (standard)
1982                       - "STD;RSI" (full standard ID)
1983                       - "PUB;abc123" (community indicator)
1984            **kwargs: Indicator-specific parameters (e.g., length=14)
1985
1986        Returns:
1987            Study ID
1988
1989        Example:
1990            >>> stream.add_study("THYAO", "1m", "RSI")
1991            >>> stream.add_study("THYAO", "1m", "MACD")
1992        """
1993        return self.studies.add(symbol, interval, indicator, **kwargs)
1994
1995    def remove_study(self, symbol: str, interval: str, indicator: str) -> None:
1996        """
1997        Remove a Pine indicator study (convenience method).
1998
1999        Args:
2000            symbol: Stock symbol
2001            interval: Chart interval
2002            indicator: Indicator name
2003
2004        Example:
2005            >>> stream.remove_study("THYAO", "1m", "RSI")
2006        """
2007        self.studies.remove(symbol, interval, indicator)
2008
2009    def get_study(
2010        self, symbol: str, interval: str, indicator: str
2011    ) -> dict[str, Any] | None:
2012        """
2013        Get latest study values (convenience method).
2014
2015        Args:
2016            symbol: Stock symbol
2017            interval: Chart interval
2018            indicator: Indicator name
2019
2020        Returns:
2021            Dict of indicator values or None.
2022
2023        Example:
2024            >>> rsi = stream.get_study("THYAO", "1m", "RSI")
2025            >>> print(rsi['value'])
2026            48.5
2027        """
2028        return self.studies.get(symbol, interval, indicator)
2029
2030    def get_studies(self, symbol: str, interval: str) -> dict[str, dict[str, Any]]:
2031        """
2032        Get all study values for a symbol/interval (convenience method).
2033
2034        Args:
2035            symbol: Stock symbol
2036            interval: Chart interval
2037
2038        Returns:
2039            Dict mapping indicator name to values.
2040
2041        Example:
2042            >>> studies = stream.get_studies("THYAO", "1m")
2043            >>> print(studies['RSI']['value'])
2044            48.5
2045        """
2046        return self.studies.get_all(symbol, interval)
2047
2048    def on_study(
2049        self,
2050        symbol: str,
2051        interval: str,
2052        indicator: str,
2053        callback: Callable[[str, str, str, dict], None],
2054    ) -> None:
2055        """
2056        Register callback for study updates (convenience method).
2057
2058        Callback signature: callback(symbol, interval, indicator, values)
2059
2060        Args:
2061            symbol: Stock symbol
2062            interval: Chart interval
2063            indicator: Indicator name
2064            callback: Function to call on each update
2065
2066        Example:
2067            >>> def on_rsi(symbol, interval, indicator, values):
2068            ...     print(f"{symbol} RSI: {values['value']}")
2069            >>> stream.on_study("THYAO", "1m", "RSI", on_rsi)
2070        """
2071        self.studies.on_update(symbol, interval, indicator, callback)
2072
2073    def on_any_study(
2074        self, callback: Callable[[str, str, str, dict], None]
2075    ) -> None:
2076        """
2077        Register callback for any study update (convenience method).
2078
2079        Args:
2080            callback: Function to call on each update.
2081
2082        Example:
2083            >>> stream.on_any_study(
2084            ...     lambda s, i, n, v: print(f"{s} {n}: {v}")
2085            ... )
2086        """
2087        self.studies.on_any_update(callback)
2088
2089    def wait_for_study(
2090        self, symbol: str, interval: str, indicator: str, timeout: float = 10.0
2091    ) -> dict[str, Any]:
2092        """
2093        Wait for study data (blocking convenience method).
2094
2095        Args:
2096            symbol: Stock symbol
2097            interval: Chart interval
2098            indicator: Indicator name
2099            timeout: Maximum wait time in seconds
2100
2101        Returns:
2102            Study values dict
2103
2104        Raises:
2105            TimeoutError: If data not received within timeout
2106
2107        Example:
2108            >>> stream.add_study("THYAO", "1m", "RSI")
2109            >>> rsi = stream.wait_for_study("THYAO", "1m", "RSI")
2110        """
2111        return self.studies.wait_for(symbol, interval, indicator, timeout)
2112
2113    # Utility methods
2114
2115    def get_all_quotes(self) -> dict[str, dict[str, Any]]:
2116        """
2117        Get all cached quotes.
2118
2119        Returns:
2120            Dict mapping symbol to quote dict.
2121        """
2122        with self._lock:
2123            return {sym: self._build_quote(sym) for sym in self._quotes}
2124
2125    def ping(self) -> float:
2126        """
2127        Measure round-trip latency.
2128
2129        Returns:
2130            Latency in milliseconds.
2131        """
2132        if not self.is_connected:
2133            return -1
2134
2135        start = time.time()
2136        # Send a heartbeat-like ping
2137        self._send(self._format_packet(f"~h~{self._heartbeat_counter}"))
2138        self._heartbeat_counter += 1
2139        elapsed = (time.time() - start) * 1000
2140        return elapsed

Persistent WebSocket connection for real-time TradingView data.

Optimized for:

  • Low latency (~50-100ms)
  • High throughput (10-20 updates/sec)
  • Multiple symbol subscriptions
  • Automatic reconnection

Attributes: is_connected: Whether the WebSocket is currently connected. subscribed_symbols: Set of currently subscribed symbols.

Examples: Basic usage::

    stream = TradingViewStream()
    stream.connect()
    stream.subscribe("THYAO")
    quote = stream.get_quote("THYAO")

With callbacks::

    def on_price_update(symbol, quote):
        print(f"{symbol}: {quote['last']}")

    stream.on_quote("THYAO", on_price_update)

Context manager::

    with TradingViewStream() as stream:
        stream.subscribe("THYAO")
        # Trading logic...
TradingViewStream(auth_token: str | None = None)
826    def __init__(self, auth_token: str | None = None):
827        """
828        Initialize TradingViewStream.
829
830        Args:
831            auth_token: Optional TradingView auth token for real-time data.
832                        If not provided, uses unauthorized token (~15min delay).
833        """
834        self._ws: websocket.WebSocketApp | None = None
835        self._ws_thread: threading.Thread | None = None
836        self._heartbeat_thread: threading.Thread | None = None
837
838        # Connection state
839        self._connected = threading.Event()
840        self._should_reconnect = True
841        self._reconnect_attempts = 0
842        self._last_heartbeat_time = 0.0
843        self._heartbeat_counter = 0
844
845        # Session management
846        self._quote_session: str | None = None
847        self._chart_session: str | None = None
848        self._auth_token = auth_token
849
850        # Data storage (thread-safe)
851        self._lock = threading.RLock()
852        self._quotes: dict[str, dict[str, Any]] = {}  # symbol -> latest quote
853        self._subscribed: set[str] = set()  # subscribed symbols
854        self._pending_subscribes: set[str] = set()  # waiting for confirmation
855
856        # Chart session data
857        # Format: {symbol: {interval: [candles]}}
858        self._chart_data: dict[str, dict[str, list[dict]]] = {}
859        self._chart_subscribed: dict[str, set[str]] = {}  # symbol -> set of intervals
860        self._chart_series_counter = 0  # Counter for unique series IDs
861        self._chart_series_map: dict[str, tuple[str, str]] = {}  # series_id -> (symbol, interval)
862
863        # Callbacks
864        self._callbacks: dict[str, list[Callable[[str, dict], None]]] = {}
865        self._global_callbacks: list[Callable[[str, dict], None]] = []
866        # Chart callbacks: {f"{symbol}:{interval}": [callbacks]}
867        self._chart_callbacks: dict[str, list[Callable[[str, str, dict], None]]] = {}
868        self._global_chart_callbacks: list[Callable[[str, str, dict], None]] = []
869
870        # Events for synchronization
871        self._quote_events: dict[str, threading.Event] = {}
872        self._chart_events: dict[str, threading.Event] = {}  # f"{symbol}:{interval}" -> Event
873
874        # Pine Script studies session (lazy-loaded)
875        self._study_session: StudySession | None = None

Initialize TradingViewStream.

Args: auth_token: Optional TradingView auth token for real-time data. If not provided, uses unauthorized token (~15min delay).

WS_URL = 'wss://data.tradingview.com/socket.io/websocket?type=chart'
ORIGIN = 'https://www.tradingview.com'
MAX_RECONNECT_ATTEMPTS = 10
MAX_RECONNECT_DELAY = 30
HEARTBEAT_INTERVAL = 30
is_connected: bool
877    @property
878    def is_connected(self) -> bool:
879        """Check if WebSocket is connected."""
880        return self._connected.is_set()

Check if WebSocket is connected.

subscribed_symbols: set[str]
882    @property
883    def subscribed_symbols(self) -> set[str]:
884        """Get set of currently subscribed symbols."""
885        with self._lock:
886            return self._subscribed.copy()

Get set of currently subscribed symbols.

def connect(self, timeout: float = 10.0) -> bool:
1403    def connect(self, timeout: float = 10.0) -> bool:
1404        """
1405        Establish persistent WebSocket connection.
1406
1407        Args:
1408            timeout: Maximum time to wait for connection in seconds.
1409
1410        Returns:
1411            True if connected successfully, False otherwise.
1412
1413        Raises:
1414            TimeoutError: If connection times out.
1415
1416        Example:
1417            >>> stream = TradingViewStream()
1418            >>> stream.connect()
1419            >>> print(stream.is_connected)
1420            True
1421        """
1422        if self.is_connected:
1423            return True
1424
1425        self._should_reconnect = True
1426        self._start_websocket()
1427
1428        # Wait for connection
1429        if not self._connected.wait(timeout=timeout):
1430            raise TimeoutError(f"Connection timed out after {timeout}s")
1431
1432        return True

Establish persistent WebSocket connection.

Args: timeout: Maximum time to wait for connection in seconds.

Returns: True if connected successfully, False otherwise.

Raises: TimeoutError: If connection times out.

Example:

stream = TradingViewStream() stream.connect() print(stream.is_connected) True

def disconnect(self) -> None:
1434    def disconnect(self) -> None:
1435        """
1436        Close WebSocket connection and cleanup.
1437
1438        Example:
1439            >>> stream.disconnect()
1440            >>> print(stream.is_connected)
1441            False
1442        """
1443        self._should_reconnect = False
1444        self._connected.clear()
1445
1446        if self._ws:
1447            self._ws.close()
1448            self._ws = None
1449
1450        if self._ws_thread and self._ws_thread.is_alive():
1451            self._ws_thread.join(timeout=2)
1452
1453        # Clear state
1454        with self._lock:
1455            # Quote state
1456            self._quotes.clear()
1457            self._subscribed.clear()
1458            self._callbacks.clear()
1459            self._global_callbacks.clear()
1460            self._quote_events.clear()
1461
1462            # Chart state
1463            self._chart_data.clear()
1464            self._chart_subscribed.clear()
1465            self._chart_series_map.clear()
1466            self._chart_callbacks.clear()
1467            self._global_chart_callbacks.clear()
1468            self._chart_events.clear()
1469
1470        logger.info("Disconnected")

Close WebSocket connection and cleanup.

Example:

stream.disconnect() print(stream.is_connected) False

def subscribe(self, symbol: str, exchange: str = 'BIST') -> None:
1472    def subscribe(self, symbol: str, exchange: str = "BIST") -> None:
1473        """
1474        Subscribe to symbol updates.
1475
1476        Args:
1477            symbol: Stock symbol (e.g., "THYAO", "GARAN")
1478            exchange: Exchange name (default: "BIST")
1479
1480        Example:
1481            >>> stream.subscribe("THYAO")
1482            >>> stream.subscribe("GARAN")
1483        """
1484        symbol = symbol.upper()
1485
1486        with self._lock:
1487            if symbol in self._subscribed:
1488                return
1489            self._subscribed.add(symbol)
1490            self._quote_events[symbol] = threading.Event()
1491
1492        if self.is_connected:
1493            self._send_subscribe(symbol, exchange)

Subscribe to symbol updates.

Args: symbol: Stock symbol (e.g., "THYAO", "GARAN") exchange: Exchange name (default: "BIST")

Example:

stream.subscribe("THYAO") stream.subscribe("GARAN")

def unsubscribe(self, symbol: str, exchange: str = 'BIST') -> None:
1495    def unsubscribe(self, symbol: str, exchange: str = "BIST") -> None:
1496        """
1497        Unsubscribe from symbol.
1498
1499        Args:
1500            symbol: Stock symbol
1501            exchange: Exchange name
1502
1503        Example:
1504            >>> stream.unsubscribe("THYAO")
1505        """
1506        symbol = symbol.upper()
1507
1508        with self._lock:
1509            self._subscribed.discard(symbol)
1510            self._quotes.pop(symbol, None)
1511            self._callbacks.pop(symbol, None)
1512            self._quote_events.pop(symbol, None)
1513
1514        if self.is_connected:
1515            self._send_unsubscribe(symbol, exchange)

Unsubscribe from symbol.

Args: symbol: Stock symbol exchange: Exchange name

Example:

stream.unsubscribe("THYAO")

def subscribe_chart(self, symbol: str, interval: str = '1m', exchange: str = 'BIST') -> None:
1519    def subscribe_chart(
1520        self, symbol: str, interval: str = "1m", exchange: str = "BIST"
1521    ) -> None:
1522        """
1523        Subscribe to OHLCV candle updates for a symbol.
1524
1525        Args:
1526            symbol: Stock symbol (e.g., "THYAO", "GARAN")
1527            interval: Candle interval. Valid values:
1528                      1m, 5m, 15m, 30m, 1h, 2h, 4h, 1d, 1wk, 1mo
1529            exchange: Exchange name (default: "BIST")
1530
1531        Example:
1532            >>> stream.subscribe_chart("THYAO", "1m")
1533            >>> stream.subscribe_chart("GARAN", "1h")
1534        """
1535        symbol = symbol.upper()
1536        interval = interval.lower()
1537
1538        # Validate interval
1539        if interval not in CHART_TIMEFRAMES:
1540            raise ValueError(
1541                f"Invalid interval '{interval}'. "
1542                f"Valid intervals: {list(CHART_TIMEFRAMES.keys())}"
1543            )
1544
1545        with self._lock:
1546            if symbol not in self._chart_subscribed:
1547                self._chart_subscribed[symbol] = set()
1548
1549            if interval in self._chart_subscribed[symbol]:
1550                return  # Already subscribed
1551
1552            self._chart_subscribed[symbol].add(interval)
1553
1554            # Initialize data storage
1555            if symbol not in self._chart_data:
1556                self._chart_data[symbol] = {}
1557            if interval not in self._chart_data[symbol]:
1558                self._chart_data[symbol][interval] = []
1559
1560            # Create event for this subscription
1561            event_key = f"{symbol}:{interval}"
1562            self._chart_events[event_key] = threading.Event()
1563
1564        if self.is_connected:
1565            self._send_chart_subscribe(symbol, interval, exchange)

Subscribe to OHLCV candle updates for a symbol.

Args: symbol: Stock symbol (e.g., "THYAO", "GARAN") interval: Candle interval. Valid values: 1m, 5m, 15m, 30m, 1h, 2h, 4h, 1d, 1wk, 1mo exchange: Exchange name (default: "BIST")

Example:

stream.subscribe_chart("THYAO", "1m") stream.subscribe_chart("GARAN", "1h")

def unsubscribe_chart(self, symbol: str, interval: str, exchange: str = 'BIST') -> None:
1567    def unsubscribe_chart(
1568        self, symbol: str, interval: str, exchange: str = "BIST"
1569    ) -> None:
1570        """
1571        Unsubscribe from chart updates.
1572
1573        Args:
1574            symbol: Stock symbol
1575            interval: Candle interval
1576            exchange: Exchange name
1577
1578        Example:
1579            >>> stream.unsubscribe_chart("THYAO", "1m")
1580        """
1581        symbol = symbol.upper()
1582        interval = interval.lower()
1583
1584        with self._lock:
1585            if symbol in self._chart_subscribed:
1586                self._chart_subscribed[symbol].discard(interval)
1587                if not self._chart_subscribed[symbol]:
1588                    del self._chart_subscribed[symbol]
1589
1590            if symbol in self._chart_data and interval in self._chart_data[symbol]:
1591                del self._chart_data[symbol][interval]
1592                if not self._chart_data[symbol]:
1593                    del self._chart_data[symbol]
1594
1595            event_key = f"{symbol}:{interval}"
1596            self._chart_events.pop(event_key, None)
1597            self._chart_callbacks.pop(event_key, None)
1598
1599        if self.is_connected:
1600            self._send_chart_unsubscribe(symbol, interval)

Unsubscribe from chart updates.

Args: symbol: Stock symbol interval: Candle interval exchange: Exchange name

Example:

stream.unsubscribe_chart("THYAO", "1m")

def get_candle(self, symbol: str, interval: str) -> dict[str, typing.Any] | None:
1602    def get_candle(self, symbol: str, interval: str) -> dict[str, Any] | None:
1603        """
1604        Get latest cached candle (instant, ~1ms).
1605
1606        Args:
1607            symbol: Stock symbol
1608            interval: Candle interval
1609
1610        Returns:
1611            Candle dict or None if not subscribed/no data yet.
1612            Candle format:
1613            {
1614                "time": 1737123456,      # Unix timestamp
1615                "open": 285.0,           # Open price
1616                "high": 286.5,           # High price
1617                "low": 284.0,            # Low price
1618                "close": 285.5,          # Close price
1619                "volume": 123456         # Volume
1620            }
1621
1622        Example:
1623            >>> candle = stream.get_candle("THYAO", "1m")
1624            >>> print(candle['close'])
1625            285.5
1626        """
1627        symbol = symbol.upper()
1628        interval = interval.lower()
1629
1630        with self._lock:
1631            if symbol not in self._chart_data:
1632                return None
1633            if interval not in self._chart_data[symbol]:
1634                return None
1635            candles = self._chart_data[symbol][interval]
1636            if not candles:
1637                return None
1638            return candles[-1].copy()

Get latest cached candle (instant, ~1ms).

Args: symbol: Stock symbol interval: Candle interval

Returns: Candle dict or None if not subscribed/no data yet. Candle format: { "time": 1737123456, # Unix timestamp "open": 285.0, # Open price "high": 286.5, # High price "low": 284.0, # Low price "close": 285.5, # Close price "volume": 123456 # Volume }

Example:

candle = stream.get_candle("THYAO", "1m") print(candle['close']) 285.5

def get_candles( self, symbol: str, interval: str, count: int | None = None) -> list[dict[str, typing.Any]]:
1640    def get_candles(
1641        self, symbol: str, interval: str, count: int | None = None
1642    ) -> list[dict[str, Any]]:
1643        """
1644        Get cached candles.
1645
1646        Args:
1647            symbol: Stock symbol
1648            interval: Candle interval
1649            count: Number of candles to return (None = all)
1650
1651        Returns:
1652            List of candle dicts, oldest first.
1653
1654        Example:
1655            >>> candles = stream.get_candles("THYAO", "1m", count=10)
1656            >>> print(len(candles))
1657            10
1658        """
1659        symbol = symbol.upper()
1660        interval = interval.lower()
1661
1662        with self._lock:
1663            if symbol not in self._chart_data:
1664                return []
1665            if interval not in self._chart_data[symbol]:
1666                return []
1667            candles = self._chart_data[symbol][interval]
1668            if count:
1669                return [c.copy() for c in candles[-count:]]
1670            return [c.copy() for c in candles]

Get cached candles.

Args: symbol: Stock symbol interval: Candle interval count: Number of candles to return (None = all)

Returns: List of candle dicts, oldest first.

Example:

candles = stream.get_candles("THYAO", "1m", count=10) print(len(candles)) 10

def wait_for_candle( self, symbol: str, interval: str, timeout: float = 5.0) -> dict[str, typing.Any]:
1672    def wait_for_candle(
1673        self, symbol: str, interval: str, timeout: float = 5.0
1674    ) -> dict[str, Any]:
1675        """
1676        Wait for first candle (blocking).
1677
1678        Useful after subscribing to ensure data is received.
1679
1680        Args:
1681            symbol: Stock symbol
1682            interval: Candle interval
1683            timeout: Maximum wait time in seconds
1684
1685        Returns:
1686            Candle dict
1687
1688        Raises:
1689            TimeoutError: If candle not received within timeout.
1690
1691        Example:
1692            >>> stream.subscribe_chart("THYAO", "1m")
1693            >>> candle = stream.wait_for_candle("THYAO", "1m")
1694        """
1695        symbol = symbol.upper()
1696        interval = interval.lower()
1697
1698        # Check if already have data
1699        candle = self.get_candle(symbol, interval)
1700        if candle:
1701            return candle
1702
1703        # Wait for data
1704        event_key = f"{symbol}:{interval}"
1705        with self._lock:
1706            if event_key not in self._chart_events:
1707                self._chart_events[event_key] = threading.Event()
1708            event = self._chart_events[event_key]
1709            event.clear()
1710
1711        if not event.wait(timeout=timeout):
1712            raise TimeoutError(f"Timeout waiting for candle: {symbol} {interval}")
1713
1714        candle = self.get_candle(symbol, interval)
1715        if candle is None:
1716            raise TimeoutError(f"No candle data received for {symbol} {interval}")
1717        return candle

Wait for first candle (blocking).

Useful after subscribing to ensure data is received.

Args: symbol: Stock symbol interval: Candle interval timeout: Maximum wait time in seconds

Returns: Candle dict

Raises: TimeoutError: If candle not received within timeout.

Example:

stream.subscribe_chart("THYAO", "1m") candle = stream.wait_for_candle("THYAO", "1m")

def on_candle( self, symbol: str, interval: str, callback: Callable[[str, str, dict], None]) -> None:
1719    def on_candle(
1720        self,
1721        symbol: str,
1722        interval: str,
1723        callback: Callable[[str, str, dict], None],
1724    ) -> None:
1725        """
1726        Register callback for candle updates.
1727
1728        Callback signature: callback(symbol: str, interval: str, candle: dict)
1729
1730        Args:
1731            symbol: Stock symbol
1732            interval: Candle interval
1733            callback: Function to call on each update
1734
1735        Example:
1736            >>> def on_candle_update(symbol, interval, candle):
1737            ...     print(f"{symbol} {interval}: O={candle['open']} C={candle['close']}")
1738            >>> stream.on_candle("THYAO", "1m", on_candle_update)
1739        """
1740        symbol = symbol.upper()
1741        interval = interval.lower()
1742        callback_key = f"{symbol}:{interval}"
1743
1744        with self._lock:
1745            if callback_key not in self._chart_callbacks:
1746                self._chart_callbacks[callback_key] = []
1747            self._chart_callbacks[callback_key].append(callback)

Register callback for candle updates.

Callback signature: callback(symbol: str, interval: str, candle: dict)

Args: symbol: Stock symbol interval: Candle interval callback: Function to call on each update

Example:

def on_candle_update(symbol, interval, candle): ... print(f"{symbol} {interval}: O={candle['open']} C={candle['close']}") stream.on_candle("THYAO", "1m", on_candle_update)

def on_any_candle(self, callback: Callable[[str, str, dict], None]) -> None:
1749    def on_any_candle(
1750        self, callback: Callable[[str, str, dict], None]
1751    ) -> None:
1752        """
1753        Register callback for all candle updates.
1754
1755        Args:
1756            callback: Function to call on each update for any subscription.
1757
1758        Example:
1759            >>> def on_any_update(symbol, interval, candle):
1760            ...     print(f"{symbol} {interval}: {candle['close']}")
1761            >>> stream.on_any_candle(on_any_update)
1762        """
1763        self._global_chart_callbacks.append(callback)

Register callback for all candle updates.

Args: callback: Function to call on each update for any subscription.

Example:

def on_any_update(symbol, interval, candle): ... print(f"{symbol} {interval}: {candle['close']}") stream.on_any_candle(on_any_update)

def remove_candle_callback( self, symbol: str, interval: str, callback: Callable[[str, str, dict], None]) -> None:
1765    def remove_candle_callback(
1766        self,
1767        symbol: str,
1768        interval: str,
1769        callback: Callable[[str, str, dict], None],
1770    ) -> None:
1771        """
1772        Remove a registered candle callback.
1773
1774        Args:
1775            symbol: Stock symbol
1776            interval: Candle interval
1777            callback: The callback to remove
1778        """
1779        symbol = symbol.upper()
1780        interval = interval.lower()
1781        callback_key = f"{symbol}:{interval}"
1782
1783        with self._lock:
1784            if callback_key in self._chart_callbacks:
1785                try:
1786                    self._chart_callbacks[callback_key].remove(callback)
1787                except ValueError:
1788                    pass

Remove a registered candle callback.

Args: symbol: Stock symbol interval: Candle interval callback: The callback to remove

chart_subscriptions: dict[str, set[str]]
1790    @property
1791    def chart_subscriptions(self) -> dict[str, set[str]]:
1792        """Get current chart subscriptions.
1793
1794        Returns:
1795            Dict mapping symbol to set of subscribed intervals.
1796        """
1797        with self._lock:
1798            return {
1799                sym: intervals.copy()
1800                for sym, intervals in self._chart_subscribed.items()
1801            }

Get current chart subscriptions.

Returns: Dict mapping symbol to set of subscribed intervals.

def get_quote(self, symbol: str) -> dict[str, typing.Any] | None:
1803    def get_quote(self, symbol: str) -> dict[str, Any] | None:
1804        """
1805        Get latest cached quote (instant, ~1ms).
1806
1807        Args:
1808            symbol: Stock symbol
1809
1810        Returns:
1811            Quote dict or None if not subscribed/no data yet.
1812
1813        Example:
1814            >>> quote = stream.get_quote("THYAO")
1815            >>> print(quote['last'])
1816            299.0
1817        """
1818        symbol = symbol.upper()
1819        with self._lock:
1820            if symbol not in self._quotes:
1821                return None
1822        return self._build_quote(symbol)

Get latest cached quote (instant, ~1ms).

Args: symbol: Stock symbol

Returns: Quote dict or None if not subscribed/no data yet.

Example:

quote = stream.get_quote("THYAO") print(quote['last']) 299.0

def wait_for_quote(self, symbol: str, timeout: float = 5.0) -> dict[str, typing.Any]:
1824    def wait_for_quote(
1825        self, symbol: str, timeout: float = 5.0
1826    ) -> dict[str, Any]:
1827        """
1828        Wait for first quote (blocking).
1829
1830        Useful after subscribing to ensure data is received.
1831
1832        Args:
1833            symbol: Stock symbol
1834            timeout: Maximum wait time in seconds
1835
1836        Returns:
1837            Quote dict
1838
1839        Raises:
1840            TimeoutError: If quote not received within timeout.
1841
1842        Example:
1843            >>> stream.subscribe("THYAO")
1844            >>> quote = stream.wait_for_quote("THYAO")
1845        """
1846        symbol = symbol.upper()
1847
1848        # Check if already have data
1849        quote = self.get_quote(symbol)
1850        if quote and quote.get("last") is not None:
1851            return quote
1852
1853        # Wait for data
1854        with self._lock:
1855            if symbol not in self._quote_events:
1856                self._quote_events[symbol] = threading.Event()
1857            event = self._quote_events[symbol]
1858            event.clear()
1859
1860        if not event.wait(timeout=timeout):
1861            raise TimeoutError(f"Timeout waiting for quote: {symbol}")
1862
1863        quote = self.get_quote(symbol)
1864        if quote is None:
1865            raise TimeoutError(f"No quote data received for {symbol}")
1866        return quote

Wait for first quote (blocking).

Useful after subscribing to ensure data is received.

Args: symbol: Stock symbol timeout: Maximum wait time in seconds

Returns: Quote dict

Raises: TimeoutError: If quote not received within timeout.

Example:

stream.subscribe("THYAO") quote = stream.wait_for_quote("THYAO")

def on_quote(self, symbol: str, callback: Callable[[str, dict], None]) -> None:
1868    def on_quote(
1869        self, symbol: str, callback: Callable[[str, dict], None]
1870    ) -> None:
1871        """
1872        Register callback for quote updates.
1873
1874        Callback signature: callback(symbol: str, quote: dict)
1875
1876        Args:
1877            symbol: Stock symbol
1878            callback: Function to call on each update
1879
1880        Example:
1881            >>> def on_price_update(symbol, quote):
1882            ...     print(f"{symbol}: {quote['last']}")
1883            >>> stream.on_quote("THYAO", on_price_update)
1884        """
1885        symbol = symbol.upper()
1886        with self._lock:
1887            if symbol not in self._callbacks:
1888                self._callbacks[symbol] = []
1889            self._callbacks[symbol].append(callback)

Register callback for quote updates.

Callback signature: callback(symbol: str, quote: dict)

Args: symbol: Stock symbol callback: Function to call on each update

Example:

def on_price_update(symbol, quote): ... print(f"{symbol}: {quote['last']}") stream.on_quote("THYAO", on_price_update)

def on_any_quote(self, callback: Callable[[str, dict], None]) -> None:
1891    def on_any_quote(self, callback: Callable[[str, dict], None]) -> None:
1892        """
1893        Register callback for all quote updates.
1894
1895        Args:
1896            callback: Function to call on each update for any symbol.
1897
1898        Example:
1899            >>> def on_any_update(symbol, quote):
1900            ...     print(f"{symbol}: {quote['last']}")
1901            >>> stream.on_any_quote(on_any_update)
1902        """
1903        self._global_callbacks.append(callback)

Register callback for all quote updates.

Args: callback: Function to call on each update for any symbol.

Example:

def on_any_update(symbol, quote): ... print(f"{symbol}: {quote['last']}") stream.on_any_quote(on_any_update)

def remove_callback(self, symbol: str, callback: Callable[[str, dict], None]) -> None:
1905    def remove_callback(
1906        self, symbol: str, callback: Callable[[str, dict], None]
1907    ) -> None:
1908        """
1909        Remove a registered callback.
1910
1911        Args:
1912            symbol: Stock symbol
1913            callback: The callback to remove
1914        """
1915        symbol = symbol.upper()
1916        with self._lock:
1917            if symbol in self._callbacks:
1918                try:
1919                    self._callbacks[symbol].remove(callback)
1920                except ValueError:
1921                    pass

Remove a registered callback.

Args: symbol: Stock symbol callback: The callback to remove

def wait(self) -> None:
1923    def wait(self) -> None:
1924        """
1925        Block until disconnect.
1926
1927        Useful for keeping the stream alive in main thread.
1928
1929        Example:
1930            >>> stream.connect()
1931            >>> stream.subscribe("THYAO")
1932            >>> stream.on_quote("THYAO", my_callback)
1933            >>> stream.wait()  # Blocks forever
1934        """
1935        if self._ws_thread:
1936            self._ws_thread.join()

Block until disconnect.

Useful for keeping the stream alive in main thread.

Example:

stream.connect() stream.subscribe("THYAO") stream.on_quote("THYAO", my_callback) stream.wait() # Blocks forever

studies: borsapy.stream.StudySession
1951    @property
1952    def studies(self) -> StudySession:
1953        """
1954        Access Pine Script study session.
1955
1956        Returns:
1957            StudySession instance for managing indicators.
1958
1959        Example:
1960            >>> stream.studies.add("THYAO", "1m", "RSI")
1961            >>> stream.studies.get("THYAO", "1m", "RSI")
1962        """
1963        if self._study_session is None:
1964            self._study_session = StudySession(self)
1965        return self._study_session

Access Pine Script study session.

Returns: StudySession instance for managing indicators.

Example:

stream.studies.add("THYAO", "1m", "RSI") stream.studies.get("THYAO", "1m", "RSI")

def add_study(self, symbol: str, interval: str, indicator: str, **kwargs: Any) -> str:
1967    def add_study(
1968        self,
1969        symbol: str,
1970        interval: str,
1971        indicator: str,
1972        **kwargs: Any,
1973    ) -> str:
1974        """
1975        Add a Pine indicator study (convenience method).
1976
1977        Args:
1978            symbol: Stock symbol (e.g., "THYAO")
1979            interval: Chart interval (e.g., "1m", "1d")
1980            indicator: Indicator name. Examples:
1981                       - "RSI", "MACD", "BB" (standard)
1982                       - "STD;RSI" (full standard ID)
1983                       - "PUB;abc123" (community indicator)
1984            **kwargs: Indicator-specific parameters (e.g., length=14)
1985
1986        Returns:
1987            Study ID
1988
1989        Example:
1990            >>> stream.add_study("THYAO", "1m", "RSI")
1991            >>> stream.add_study("THYAO", "1m", "MACD")
1992        """
1993        return self.studies.add(symbol, interval, indicator, **kwargs)

Add a Pine indicator study (convenience method).

Args: symbol: Stock symbol (e.g., "THYAO") interval: Chart interval (e.g., "1m", "1d") indicator: Indicator name. Examples: - "RSI", "MACD", "BB" (standard) - "STD;RSI" (full standard ID) - "PUB;abc123" (community indicator) **kwargs: Indicator-specific parameters (e.g., length=14)

Returns: Study ID

Example:

stream.add_study("THYAO", "1m", "RSI") stream.add_study("THYAO", "1m", "MACD")

def remove_study(self, symbol: str, interval: str, indicator: str) -> None:
1995    def remove_study(self, symbol: str, interval: str, indicator: str) -> None:
1996        """
1997        Remove a Pine indicator study (convenience method).
1998
1999        Args:
2000            symbol: Stock symbol
2001            interval: Chart interval
2002            indicator: Indicator name
2003
2004        Example:
2005            >>> stream.remove_study("THYAO", "1m", "RSI")
2006        """
2007        self.studies.remove(symbol, interval, indicator)

Remove a Pine indicator study (convenience method).

Args: symbol: Stock symbol interval: Chart interval indicator: Indicator name

Example:

stream.remove_study("THYAO", "1m", "RSI")

def get_study( self, symbol: str, interval: str, indicator: str) -> dict[str, typing.Any] | None:
2009    def get_study(
2010        self, symbol: str, interval: str, indicator: str
2011    ) -> dict[str, Any] | None:
2012        """
2013        Get latest study values (convenience method).
2014
2015        Args:
2016            symbol: Stock symbol
2017            interval: Chart interval
2018            indicator: Indicator name
2019
2020        Returns:
2021            Dict of indicator values or None.
2022
2023        Example:
2024            >>> rsi = stream.get_study("THYAO", "1m", "RSI")
2025            >>> print(rsi['value'])
2026            48.5
2027        """
2028        return self.studies.get(symbol, interval, indicator)

Get latest study values (convenience method).

Args: symbol: Stock symbol interval: Chart interval indicator: Indicator name

Returns: Dict of indicator values or None.

Example:

rsi = stream.get_study("THYAO", "1m", "RSI") print(rsi['value']) 48.5

def get_studies(self, symbol: str, interval: str) -> dict[str, dict[str, typing.Any]]:
2030    def get_studies(self, symbol: str, interval: str) -> dict[str, dict[str, Any]]:
2031        """
2032        Get all study values for a symbol/interval (convenience method).
2033
2034        Args:
2035            symbol: Stock symbol
2036            interval: Chart interval
2037
2038        Returns:
2039            Dict mapping indicator name to values.
2040
2041        Example:
2042            >>> studies = stream.get_studies("THYAO", "1m")
2043            >>> print(studies['RSI']['value'])
2044            48.5
2045        """
2046        return self.studies.get_all(symbol, interval)

Get all study values for a symbol/interval (convenience method).

Args: symbol: Stock symbol interval: Chart interval

Returns: Dict mapping indicator name to values.

Example:

studies = stream.get_studies("THYAO", "1m") print(studies['RSI']['value']) 48.5

def on_study( self, symbol: str, interval: str, indicator: str, callback: Callable[[str, str, str, dict], None]) -> None:
2048    def on_study(
2049        self,
2050        symbol: str,
2051        interval: str,
2052        indicator: str,
2053        callback: Callable[[str, str, str, dict], None],
2054    ) -> None:
2055        """
2056        Register callback for study updates (convenience method).
2057
2058        Callback signature: callback(symbol, interval, indicator, values)
2059
2060        Args:
2061            symbol: Stock symbol
2062            interval: Chart interval
2063            indicator: Indicator name
2064            callback: Function to call on each update
2065
2066        Example:
2067            >>> def on_rsi(symbol, interval, indicator, values):
2068            ...     print(f"{symbol} RSI: {values['value']}")
2069            >>> stream.on_study("THYAO", "1m", "RSI", on_rsi)
2070        """
2071        self.studies.on_update(symbol, interval, indicator, callback)

Register callback for study updates (convenience method).

Callback signature: callback(symbol, interval, indicator, values)

Args: symbol: Stock symbol interval: Chart interval indicator: Indicator name callback: Function to call on each update

Example:

def on_rsi(symbol, interval, indicator, values): ... print(f"{symbol} RSI: {values['value']}") stream.on_study("THYAO", "1m", "RSI", on_rsi)

def on_any_study(self, callback: Callable[[str, str, str, dict], None]) -> None:
2073    def on_any_study(
2074        self, callback: Callable[[str, str, str, dict], None]
2075    ) -> None:
2076        """
2077        Register callback for any study update (convenience method).
2078
2079        Args:
2080            callback: Function to call on each update.
2081
2082        Example:
2083            >>> stream.on_any_study(
2084            ...     lambda s, i, n, v: print(f"{s} {n}: {v}")
2085            ... )
2086        """
2087        self.studies.on_any_update(callback)

Register callback for any study update (convenience method).

Args: callback: Function to call on each update.

Example:

stream.on_any_study( ... lambda s, i, n, v: print(f"{s} {n}: {v}") ... )

def wait_for_study( self, symbol: str, interval: str, indicator: str, timeout: float = 10.0) -> dict[str, typing.Any]:
2089    def wait_for_study(
2090        self, symbol: str, interval: str, indicator: str, timeout: float = 10.0
2091    ) -> dict[str, Any]:
2092        """
2093        Wait for study data (blocking convenience method).
2094
2095        Args:
2096            symbol: Stock symbol
2097            interval: Chart interval
2098            indicator: Indicator name
2099            timeout: Maximum wait time in seconds
2100
2101        Returns:
2102            Study values dict
2103
2104        Raises:
2105            TimeoutError: If data not received within timeout
2106
2107        Example:
2108            >>> stream.add_study("THYAO", "1m", "RSI")
2109            >>> rsi = stream.wait_for_study("THYAO", "1m", "RSI")
2110        """
2111        return self.studies.wait_for(symbol, interval, indicator, timeout)

Wait for study data (blocking convenience method).

Args: symbol: Stock symbol interval: Chart interval indicator: Indicator name timeout: Maximum wait time in seconds

Returns: Study values dict

Raises: TimeoutError: If data not received within timeout

Example:

stream.add_study("THYAO", "1m", "RSI") rsi = stream.wait_for_study("THYAO", "1m", "RSI")

def get_all_quotes(self) -> dict[str, dict[str, typing.Any]]:
2115    def get_all_quotes(self) -> dict[str, dict[str, Any]]:
2116        """
2117        Get all cached quotes.
2118
2119        Returns:
2120            Dict mapping symbol to quote dict.
2121        """
2122        with self._lock:
2123            return {sym: self._build_quote(sym) for sym in self._quotes}

Get all cached quotes.

Returns: Dict mapping symbol to quote dict.

def ping(self) -> float:
2125    def ping(self) -> float:
2126        """
2127        Measure round-trip latency.
2128
2129        Returns:
2130            Latency in milliseconds.
2131        """
2132        if not self.is_connected:
2133            return -1
2134
2135        start = time.time()
2136        # Send a heartbeat-like ping
2137        self._send(self._format_packet(f"~h~{self._heartbeat_counter}"))
2138        self._heartbeat_counter += 1
2139        elapsed = (time.time() - start) * 1000
2140        return elapsed

Measure round-trip latency.

Returns: Latency in milliseconds.

class ReplaySession:
 49class ReplaySession:
 50    """
 51    Replay historical market data for backtesting.
 52
 53    Provides a generator-based interface for iterating over historical
 54    OHLCV candles with speed control and callback support.
 55
 56    Attributes:
 57        symbol: The stock symbol being replayed.
 58        speed: Playback speed multiplier (1.0 = real-time).
 59        total_candles: Total number of candles in the dataset.
 60
 61    Examples:
 62        Basic usage::
 63
 64            session = ReplaySession("THYAO", df=historical_data, speed=10.0)
 65            for candle in session.replay():
 66                # Implement trading logic
 67                pass
 68
 69        With callbacks::
 70
 71            def my_callback(candle):
 72                print(f"Candle {candle['_index']}: {candle['close']}")
 73
 74            session.on_candle(my_callback)
 75            list(session.replay())  # Callbacks fire for each candle
 76    """
 77
 78    def __init__(
 79        self,
 80        symbol: str,
 81        df: pd.DataFrame | None = None,
 82        speed: float = 1.0,
 83        realtime_injection: bool = False,
 84    ):
 85        """
 86        Initialize ReplaySession.
 87
 88        Args:
 89            symbol: Stock symbol (e.g., "THYAO")
 90            df: DataFrame with OHLCV data. Must have DatetimeIndex and
 91                columns: Open, High, Low, Close, Volume.
 92                If None, use create_replay() to load data automatically.
 93            speed: Playback speed multiplier.
 94                   1.0 = real-time (60s between daily candles)
 95                   2.0 = 2x speed (30s between daily candles)
 96                   100.0 = fast forward (0.6s between daily candles)
 97                   0.0 or negative = no delay (as fast as possible)
 98            realtime_injection: If True, candles are yielded at the
 99                               original time interval divided by speed.
100                               If False, yields immediately with no delay.
101
102        Raises:
103            ValueError: If df is provided but missing required columns.
104        """
105        self.symbol = symbol.upper()
106        self.speed = max(0.0, speed)
107        self.realtime_injection = realtime_injection
108        self._df: pd.DataFrame | None = df
109        self._callbacks: list[Callable[[dict], None]] = []
110        self._current_index = 0
111        self._start_time: float | None = None
112
113        # Validate DataFrame if provided
114        if df is not None:
115            self._validate_dataframe(df)
116
117    def _validate_dataframe(self, df: pd.DataFrame) -> None:
118        """Validate that DataFrame has required columns."""
119        required = {"Open", "High", "Low", "Close"}
120        missing = required - set(df.columns)
121        if missing:
122            raise ValueError(f"DataFrame missing required columns: {missing}")
123
124    @property
125    def total_candles(self) -> int:
126        """Get total number of candles."""
127        if self._df is None:
128            return 0
129        return len(self._df)
130
131    @property
132    def progress(self) -> float:
133        """Get current replay progress (0.0 to 1.0)."""
134        if self.total_candles == 0:
135            return 0.0
136        return self._current_index / self.total_candles
137
138    def set_data(self, df: pd.DataFrame) -> None:
139        """
140        Set the DataFrame for replay.
141
142        Args:
143            df: DataFrame with OHLCV data.
144        """
145        self._validate_dataframe(df)
146        self._df = df
147        self._current_index = 0
148
149    def on_candle(self, callback: Callable[[dict], None]) -> None:
150        """
151        Register callback for candle updates.
152
153        Callback signature: callback(candle: dict)
154
155        Args:
156            callback: Function to call for each candle during replay.
157
158        Example:
159            >>> def my_handler(candle):
160            ...     print(f"Price: {candle['close']}")
161            >>> session.on_candle(my_handler)
162        """
163        self._callbacks.append(callback)
164
165    def remove_callback(self, callback: Callable[[dict], None]) -> None:
166        """
167        Remove a registered callback.
168
169        Args:
170            callback: The callback to remove.
171        """
172        try:
173            self._callbacks.remove(callback)
174        except ValueError:
175            pass
176
177    def _build_candle(self, idx: int) -> dict[str, Any]:
178        """Build candle dict from DataFrame row."""
179        if self._df is None:
180            return {}
181
182        row = self._df.iloc[idx]
183        timestamp = self._df.index[idx]
184
185        # Convert timestamp to datetime if needed
186        if isinstance(timestamp, pd.Timestamp):
187            timestamp = timestamp.to_pydatetime()
188
189        candle = {
190            "timestamp": timestamp,
191            "open": float(row["Open"]),
192            "high": float(row["High"]),
193            "low": float(row["Low"]),
194            "close": float(row["Close"]),
195            "volume": float(row.get("Volume", 0)) if "Volume" in row else 0,
196            "_index": idx,
197            "_total": len(self._df),
198            "_progress": (idx + 1) / len(self._df),
199        }
200
201        return candle
202
203    def _calculate_delay(self, current_idx: int) -> float:
204        """Calculate delay between candles based on speed."""
205        if not self.realtime_injection or self.speed <= 0:
206            return 0.0
207
208        if self._df is None or current_idx == 0:
209            return 0.0
210
211        # Calculate time difference between candles
212        current_time = self._df.index[current_idx]
213        prev_time = self._df.index[current_idx - 1]
214
215        if isinstance(current_time, pd.Timestamp):
216            current_time = current_time.to_pydatetime()
217        if isinstance(prev_time, pd.Timestamp):
218            prev_time = prev_time.to_pydatetime()
219
220        time_diff = (current_time - prev_time).total_seconds()
221
222        # Apply speed multiplier
223        return time_diff / self.speed
224
225    def replay(self) -> Generator[dict[str, Any], None, None]:
226        """
227        Generator that yields candles one by one.
228
229        Yields candles from the dataset with optional time delay
230        based on speed setting.
231
232        Yields:
233            Candle dict with keys:
234            - timestamp: datetime of the candle
235            - open: Open price
236            - high: High price
237            - low: Low price
238            - close: Close price
239            - volume: Trading volume
240            - _index: Current candle index (0-based)
241            - _total: Total number of candles
242            - _progress: Progress ratio (0.0 to 1.0)
243
244        Example:
245            >>> for candle in session.replay():
246            ...     print(f"{candle['timestamp']}: {candle['close']}")
247        """
248        if self._df is None or len(self._df) == 0:
249            return
250
251        self._current_index = 0
252        self._start_time = time.time()
253
254        for idx in range(len(self._df)):
255            self._current_index = idx
256
257            # Calculate and apply delay
258            if idx > 0:
259                delay = self._calculate_delay(idx)
260                if delay > 0:
261                    time.sleep(delay)
262
263            # Build candle
264            candle = self._build_candle(idx)
265
266            # Fire callbacks
267            for callback in self._callbacks:
268                try:
269                    callback(candle)
270                except Exception:
271                    pass  # Silently ignore callback errors
272
273            yield candle
274
275    def replay_filtered(
276        self,
277        start_date: str | datetime | None = None,
278        end_date: str | datetime | None = None,
279    ) -> Generator[dict[str, Any], None, None]:
280        """
281        Generator that yields filtered candles.
282
283        Filter candles by date range before replay.
284
285        Args:
286            start_date: Start date (inclusive). Can be string "YYYY-MM-DD"
287                       or datetime object.
288            end_date: End date (inclusive). Can be string "YYYY-MM-DD"
289                     or datetime object.
290
291        Yields:
292            Filtered candle dicts (same format as replay()).
293
294        Example:
295            >>> for candle in session.replay_filtered(
296            ...     start_date="2024-01-01",
297            ...     end_date="2024-06-30"
298            ... ):
299            ...     # Process candle in date range
300            ...     pass
301        """
302        if self._df is None or len(self._df) == 0:
303            return
304
305        # Parse dates
306        if isinstance(start_date, str):
307            start_date = pd.to_datetime(start_date)
308        if isinstance(end_date, str):
309            end_date = pd.to_datetime(end_date)
310
311        # Handle timezone compatibility
312        # If DataFrame index is timezone-aware, convert filter dates to match
313        if self._df.index.tz is not None:
314            if start_date is not None and start_date.tzinfo is None:
315                start_date = start_date.tz_localize(self._df.index.tz)
316            if end_date is not None and end_date.tzinfo is None:
317                end_date = end_date.tz_localize(self._df.index.tz)
318
319        # Filter DataFrame using .date() comparison for timezone safety
320        mask = pd.Series([True] * len(self._df), index=self._df.index)
321
322        if start_date is not None:
323            mask &= self._df.index >= start_date
324
325        if end_date is not None:
326            mask &= self._df.index <= end_date
327
328        filtered_indices = self._df.index[mask]
329
330        self._current_index = 0
331        self._start_time = time.time()
332
333        for i, idx in enumerate(filtered_indices):
334            self._current_index = i
335
336            # Get the position in original DataFrame
337            original_idx = self._df.index.get_loc(idx)
338
339            # Calculate and apply delay
340            if i > 0:
341                delay = self._calculate_delay(original_idx)
342                if delay > 0:
343                    time.sleep(delay)
344
345            # Build candle
346            candle = self._build_candle(original_idx)
347
348            # Update filtered progress
349            candle["_index"] = i
350            candle["_total"] = len(filtered_indices)
351            candle["_progress"] = (i + 1) / len(filtered_indices)
352
353            # Fire callbacks
354            for callback in self._callbacks:
355                try:
356                    callback(candle)
357                except Exception:
358                    pass
359
360            yield candle
361
362    def stats(self) -> dict[str, Any]:
363        """
364        Get replay statistics.
365
366        Returns:
367            Dict with statistics:
368            - symbol: Stock symbol
369            - total_candles: Total number of candles
370            - current_index: Current position in replay
371            - progress: Progress ratio (0.0 to 1.0)
372            - speed: Playback speed multiplier
373            - elapsed_time: Time elapsed since replay start (seconds)
374            - start_date: First candle date
375            - end_date: Last candle date
376
377        Example:
378            >>> print(session.stats())
379            {'symbol': 'THYAO', 'total_candles': 252, ...}
380        """
381        stats = {
382            "symbol": self.symbol,
383            "total_candles": self.total_candles,
384            "current_index": self._current_index,
385            "progress": self.progress,
386            "speed": self.speed,
387            "realtime_injection": self.realtime_injection,
388            "elapsed_time": (
389                time.time() - self._start_time if self._start_time else 0.0
390            ),
391            "start_date": None,
392            "end_date": None,
393            "callbacks_registered": len(self._callbacks),
394        }
395
396        if self._df is not None and len(self._df) > 0:
397            stats["start_date"] = self._df.index[0]
398            stats["end_date"] = self._df.index[-1]
399
400            if isinstance(stats["start_date"], pd.Timestamp):
401                stats["start_date"] = stats["start_date"].to_pydatetime()
402            if isinstance(stats["end_date"], pd.Timestamp):
403                stats["end_date"] = stats["end_date"].to_pydatetime()
404
405        return stats
406
407    def reset(self) -> None:
408        """Reset replay to beginning."""
409        self._current_index = 0
410        self._start_time = None

Replay historical market data for backtesting.

Provides a generator-based interface for iterating over historical OHLCV candles with speed control and callback support.

Attributes: symbol: The stock symbol being replayed. speed: Playback speed multiplier (1.0 = real-time). total_candles: Total number of candles in the dataset.

Examples: Basic usage::

    session = ReplaySession("THYAO", df=historical_data, speed=10.0)
    for candle in session.replay():
        # Implement trading logic
        pass

With callbacks::

    def my_callback(candle):
        print(f"Candle {candle['_index']}: {candle['close']}")

    session.on_candle(my_callback)
    list(session.replay())  # Callbacks fire for each candle
ReplaySession( symbol: str, df: pandas.core.frame.DataFrame | None = None, speed: float = 1.0, realtime_injection: bool = False)
 78    def __init__(
 79        self,
 80        symbol: str,
 81        df: pd.DataFrame | None = None,
 82        speed: float = 1.0,
 83        realtime_injection: bool = False,
 84    ):
 85        """
 86        Initialize ReplaySession.
 87
 88        Args:
 89            symbol: Stock symbol (e.g., "THYAO")
 90            df: DataFrame with OHLCV data. Must have DatetimeIndex and
 91                columns: Open, High, Low, Close, Volume.
 92                If None, use create_replay() to load data automatically.
 93            speed: Playback speed multiplier.
 94                   1.0 = real-time (60s between daily candles)
 95                   2.0 = 2x speed (30s between daily candles)
 96                   100.0 = fast forward (0.6s between daily candles)
 97                   0.0 or negative = no delay (as fast as possible)
 98            realtime_injection: If True, candles are yielded at the
 99                               original time interval divided by speed.
100                               If False, yields immediately with no delay.
101
102        Raises:
103            ValueError: If df is provided but missing required columns.
104        """
105        self.symbol = symbol.upper()
106        self.speed = max(0.0, speed)
107        self.realtime_injection = realtime_injection
108        self._df: pd.DataFrame | None = df
109        self._callbacks: list[Callable[[dict], None]] = []
110        self._current_index = 0
111        self._start_time: float | None = None
112
113        # Validate DataFrame if provided
114        if df is not None:
115            self._validate_dataframe(df)

Initialize ReplaySession.

Args: symbol: Stock symbol (e.g., "THYAO") df: DataFrame with OHLCV data. Must have DatetimeIndex and columns: Open, High, Low, Close, Volume. If None, use create_replay() to load data automatically. speed: Playback speed multiplier. 1.0 = real-time (60s between daily candles) 2.0 = 2x speed (30s between daily candles) 100.0 = fast forward (0.6s between daily candles) 0.0 or negative = no delay (as fast as possible) realtime_injection: If True, candles are yielded at the original time interval divided by speed. If False, yields immediately with no delay.

Raises: ValueError: If df is provided but missing required columns.

symbol
speed
realtime_injection
total_candles: int
124    @property
125    def total_candles(self) -> int:
126        """Get total number of candles."""
127        if self._df is None:
128            return 0
129        return len(self._df)

Get total number of candles.

progress: float
131    @property
132    def progress(self) -> float:
133        """Get current replay progress (0.0 to 1.0)."""
134        if self.total_candles == 0:
135            return 0.0
136        return self._current_index / self.total_candles

Get current replay progress (0.0 to 1.0).

def set_data(self, df: pandas.core.frame.DataFrame) -> None:
138    def set_data(self, df: pd.DataFrame) -> None:
139        """
140        Set the DataFrame for replay.
141
142        Args:
143            df: DataFrame with OHLCV data.
144        """
145        self._validate_dataframe(df)
146        self._df = df
147        self._current_index = 0

Set the DataFrame for replay.

Args: df: DataFrame with OHLCV data.

def on_candle(self, callback: Callable[[dict], None]) -> None:
149    def on_candle(self, callback: Callable[[dict], None]) -> None:
150        """
151        Register callback for candle updates.
152
153        Callback signature: callback(candle: dict)
154
155        Args:
156            callback: Function to call for each candle during replay.
157
158        Example:
159            >>> def my_handler(candle):
160            ...     print(f"Price: {candle['close']}")
161            >>> session.on_candle(my_handler)
162        """
163        self._callbacks.append(callback)

Register callback for candle updates.

Callback signature: callback(candle: dict)

Args: callback: Function to call for each candle during replay.

Example:

def my_handler(candle): ... print(f"Price: {candle['close']}") session.on_candle(my_handler)

def remove_callback(self, callback: Callable[[dict], None]) -> None:
165    def remove_callback(self, callback: Callable[[dict], None]) -> None:
166        """
167        Remove a registered callback.
168
169        Args:
170            callback: The callback to remove.
171        """
172        try:
173            self._callbacks.remove(callback)
174        except ValueError:
175            pass

Remove a registered callback.

Args: callback: The callback to remove.

def replay(self) -> Generator[dict[str, typing.Any], None, None]:
225    def replay(self) -> Generator[dict[str, Any], None, None]:
226        """
227        Generator that yields candles one by one.
228
229        Yields candles from the dataset with optional time delay
230        based on speed setting.
231
232        Yields:
233            Candle dict with keys:
234            - timestamp: datetime of the candle
235            - open: Open price
236            - high: High price
237            - low: Low price
238            - close: Close price
239            - volume: Trading volume
240            - _index: Current candle index (0-based)
241            - _total: Total number of candles
242            - _progress: Progress ratio (0.0 to 1.0)
243
244        Example:
245            >>> for candle in session.replay():
246            ...     print(f"{candle['timestamp']}: {candle['close']}")
247        """
248        if self._df is None or len(self._df) == 0:
249            return
250
251        self._current_index = 0
252        self._start_time = time.time()
253
254        for idx in range(len(self._df)):
255            self._current_index = idx
256
257            # Calculate and apply delay
258            if idx > 0:
259                delay = self._calculate_delay(idx)
260                if delay > 0:
261                    time.sleep(delay)
262
263            # Build candle
264            candle = self._build_candle(idx)
265
266            # Fire callbacks
267            for callback in self._callbacks:
268                try:
269                    callback(candle)
270                except Exception:
271                    pass  # Silently ignore callback errors
272
273            yield candle

Generator that yields candles one by one.

Yields candles from the dataset with optional time delay based on speed setting.

Yields: Candle dict with keys: - timestamp: datetime of the candle - open: Open price - high: High price - low: Low price - close: Close price - volume: Trading volume - _index: Current candle index (0-based) - _total: Total number of candles - _progress: Progress ratio (0.0 to 1.0)

Example:

for candle in session.replay(): ... print(f"{candle['timestamp']}: {candle['close']}")

def replay_filtered( self, start_date: str | datetime.datetime | None = None, end_date: str | datetime.datetime | None = None) -> Generator[dict[str, typing.Any], None, None]:
275    def replay_filtered(
276        self,
277        start_date: str | datetime | None = None,
278        end_date: str | datetime | None = None,
279    ) -> Generator[dict[str, Any], None, None]:
280        """
281        Generator that yields filtered candles.
282
283        Filter candles by date range before replay.
284
285        Args:
286            start_date: Start date (inclusive). Can be string "YYYY-MM-DD"
287                       or datetime object.
288            end_date: End date (inclusive). Can be string "YYYY-MM-DD"
289                     or datetime object.
290
291        Yields:
292            Filtered candle dicts (same format as replay()).
293
294        Example:
295            >>> for candle in session.replay_filtered(
296            ...     start_date="2024-01-01",
297            ...     end_date="2024-06-30"
298            ... ):
299            ...     # Process candle in date range
300            ...     pass
301        """
302        if self._df is None or len(self._df) == 0:
303            return
304
305        # Parse dates
306        if isinstance(start_date, str):
307            start_date = pd.to_datetime(start_date)
308        if isinstance(end_date, str):
309            end_date = pd.to_datetime(end_date)
310
311        # Handle timezone compatibility
312        # If DataFrame index is timezone-aware, convert filter dates to match
313        if self._df.index.tz is not None:
314            if start_date is not None and start_date.tzinfo is None:
315                start_date = start_date.tz_localize(self._df.index.tz)
316            if end_date is not None and end_date.tzinfo is None:
317                end_date = end_date.tz_localize(self._df.index.tz)
318
319        # Filter DataFrame using .date() comparison for timezone safety
320        mask = pd.Series([True] * len(self._df), index=self._df.index)
321
322        if start_date is not None:
323            mask &= self._df.index >= start_date
324
325        if end_date is not None:
326            mask &= self._df.index <= end_date
327
328        filtered_indices = self._df.index[mask]
329
330        self._current_index = 0
331        self._start_time = time.time()
332
333        for i, idx in enumerate(filtered_indices):
334            self._current_index = i
335
336            # Get the position in original DataFrame
337            original_idx = self._df.index.get_loc(idx)
338
339            # Calculate and apply delay
340            if i > 0:
341                delay = self._calculate_delay(original_idx)
342                if delay > 0:
343                    time.sleep(delay)
344
345            # Build candle
346            candle = self._build_candle(original_idx)
347
348            # Update filtered progress
349            candle["_index"] = i
350            candle["_total"] = len(filtered_indices)
351            candle["_progress"] = (i + 1) / len(filtered_indices)
352
353            # Fire callbacks
354            for callback in self._callbacks:
355                try:
356                    callback(candle)
357                except Exception:
358                    pass
359
360            yield candle

Generator that yields filtered candles.

Filter candles by date range before replay.

Args: start_date: Start date (inclusive). Can be string "YYYY-MM-DD" or datetime object. end_date: End date (inclusive). Can be string "YYYY-MM-DD" or datetime object.

Yields: Filtered candle dicts (same format as replay()).

Example:

for candle in session.replay_filtered( ... start_date="2024-01-01", ... end_date="2024-06-30" ... ): ... # Process candle in date range ... pass

def stats(self) -> dict[str, typing.Any]:
362    def stats(self) -> dict[str, Any]:
363        """
364        Get replay statistics.
365
366        Returns:
367            Dict with statistics:
368            - symbol: Stock symbol
369            - total_candles: Total number of candles
370            - current_index: Current position in replay
371            - progress: Progress ratio (0.0 to 1.0)
372            - speed: Playback speed multiplier
373            - elapsed_time: Time elapsed since replay start (seconds)
374            - start_date: First candle date
375            - end_date: Last candle date
376
377        Example:
378            >>> print(session.stats())
379            {'symbol': 'THYAO', 'total_candles': 252, ...}
380        """
381        stats = {
382            "symbol": self.symbol,
383            "total_candles": self.total_candles,
384            "current_index": self._current_index,
385            "progress": self.progress,
386            "speed": self.speed,
387            "realtime_injection": self.realtime_injection,
388            "elapsed_time": (
389                time.time() - self._start_time if self._start_time else 0.0
390            ),
391            "start_date": None,
392            "end_date": None,
393            "callbacks_registered": len(self._callbacks),
394        }
395
396        if self._df is not None and len(self._df) > 0:
397            stats["start_date"] = self._df.index[0]
398            stats["end_date"] = self._df.index[-1]
399
400            if isinstance(stats["start_date"], pd.Timestamp):
401                stats["start_date"] = stats["start_date"].to_pydatetime()
402            if isinstance(stats["end_date"], pd.Timestamp):
403                stats["end_date"] = stats["end_date"].to_pydatetime()
404
405        return stats

Get replay statistics.

Returns: Dict with statistics: - symbol: Stock symbol - total_candles: Total number of candles - current_index: Current position in replay - progress: Progress ratio (0.0 to 1.0) - speed: Playback speed multiplier - elapsed_time: Time elapsed since replay start (seconds) - start_date: First candle date - end_date: Last candle date

Example:

print(session.stats()) {'symbol': 'THYAO', 'total_candles': 252, ...}

def reset(self) -> None:
407    def reset(self) -> None:
408        """Reset replay to beginning."""
409        self._current_index = 0
410        self._start_time = None

Reset replay to beginning.

def companies() -> pandas.core.frame.DataFrame:
 9def companies() -> pd.DataFrame:
10    """
11    Get list of all BIST companies.
12
13    Returns:
14        DataFrame with columns:
15        - ticker: Stock ticker code (e.g., "THYAO", "GARAN")
16        - name: Company name
17        - city: Company headquarters city
18
19    Examples:
20        >>> import borsapy as bp
21        >>> bp.companies()
22              ticker                                    name        city
23        0      ACSEL            ACIPAYAM SELULOZ SANAYI A.S.     DENIZLI
24        1      ADEL                  ADEL KALEMCILIK A.S.    ISTANBUL
25        ...
26    """
27    provider = get_kap_provider()
28    return provider.get_companies()

Get list of all BIST companies.

Returns: DataFrame with columns: - ticker: Stock ticker code (e.g., "THYAO", "GARAN") - name: Company name - city: Company headquarters city

Examples:

import borsapy as bp bp.companies() ticker name city 0 ACSEL ACIPAYAM SELULOZ SANAYI A.S. DENIZLI 1 ADEL ADEL KALEMCILIK A.S. ISTANBUL ...

def search_companies(query: str) -> pandas.core.frame.DataFrame:
31def search_companies(query: str) -> pd.DataFrame:
32    """
33    Search BIST companies by name or ticker.
34
35    Args:
36        query: Search query (ticker code or company name)
37
38    Returns:
39        DataFrame with matching companies, sorted by relevance.
40
41    Examples:
42        >>> import borsapy as bp
43        >>> bp.search_companies("THYAO")
44              ticker                                    name        city
45        0      THYAO          TURK HAVA YOLLARI A.O.    ISTANBUL
46
47        >>> bp.search_companies("banka")
48              ticker                                    name        city
49        0      GARAN              TURKIYE GARANTI BANKASI A.S.  ISTANBUL
50        1      AKBNK              AKBANK T.A.S.               ISTANBUL
51        ...
52    """
53    provider = get_kap_provider()
54    return provider.search(query)

Search BIST companies by name or ticker.

Args: query: Search query (ticker code or company name)

Returns: DataFrame with matching companies, sorted by relevance.

Examples:

import borsapy as bp bp.search_companies("THYAO") ticker name city 0 THYAO TURK HAVA YOLLARI A.O. ISTANBUL

>>> bp.search_companies("banka")
      ticker                                    name        city
0      GARAN              TURKIYE GARANTI BANKASI A.S.  ISTANBUL
1      AKBNK              AKBANK T.A.S.               ISTANBUL
...
def search_bist(query: str, limit: int = 50) -> list[str]:
180def search_bist(query: str, limit: int = 50) -> list[str]:
181    """Search BIST symbols only.
182
183    Convenience function for Turkish stock search.
184
185    Args:
186        query: Search query
187        limit: Maximum results
188
189    Returns:
190        List of BIST symbol strings
191
192    Examples:
193        >>> bp.search_bist("banka")
194        ['AKBNK', 'GARAN', 'ISCTR', 'YKBNK', 'HALKB']
195    """
196    return search(query, type="stock", exchange="BIST", limit=limit)

Search BIST symbols only.

Convenience function for Turkish stock search.

Args: query: Search query limit: Maximum results

Returns: List of BIST symbol strings

Examples:

bp.search_bist("banka") ['AKBNK', 'GARAN', 'ISCTR', 'YKBNK', 'HALKB']

def search_crypto(query: str, limit: int = 50) -> list[str]:
199def search_crypto(query: str, limit: int = 50) -> list[str]:
200    """Search cryptocurrency symbols.
201
202    Args:
203        query: Search query (e.g., "BTC", "ETH")
204        limit: Maximum results
205
206    Returns:
207        List of crypto symbol strings
208
209    Examples:
210        >>> bp.search_crypto("BTC")
211        ['BTCUSD', 'BTCTRY', 'BTCUSDT', ...]
212    """
213    return search(query, type="crypto", limit=limit)

Search cryptocurrency symbols.

Args: query: Search query (e.g., "BTC", "ETH") limit: Maximum results

Returns: List of crypto symbol strings

Examples:

bp.search_crypto("BTC") ['BTCUSD', 'BTCTRY', 'BTCUSDT', ...]

def search_forex(query: str, limit: int = 50) -> list[str]:
216def search_forex(query: str, limit: int = 50) -> list[str]:
217    """Search forex symbols.
218
219    Args:
220        query: Search query (e.g., "USD", "EUR", "gold")
221        limit: Maximum results
222
223    Returns:
224        List of forex symbol strings
225
226    Examples:
227        >>> bp.search_forex("gold")
228        ['XAUUSD', 'XAUTRY', ...]
229    """
230    return search(query, type="forex", limit=limit)

Search forex symbols.

Args: query: Search query (e.g., "USD", "EUR", "gold") limit: Maximum results

Returns: List of forex symbol strings

Examples:

bp.search_forex("gold") ['XAUUSD', 'XAUTRY', ...]

def search_index(query: str, limit: int = 50) -> list[str]:
233def search_index(query: str, limit: int = 50) -> list[str]:
234    """Search market index symbols.
235
236    Args:
237        query: Search query (e.g., "XU", "SP500")
238        limit: Maximum results
239
240    Returns:
241        List of index symbol strings
242
243    Examples:
244        >>> bp.search_index("XU")
245        ['XU100', 'XU030', 'XU050', ...]
246    """
247    return search(query, type="index", limit=limit)

Search market index symbols.

Args: query: Search query (e.g., "XU", "SP500") limit: Maximum results

Returns: List of index symbol strings

Examples:

bp.search_index("XU") ['XU100', 'XU030', 'XU050', ...]

def search_viop(query: str, limit: int = 50) -> list[str]:
250def search_viop(query: str, limit: int = 50) -> list[str]:
251    """Search VIOP (Turkish derivatives) symbols.
252
253    Searches for futures and options contracts on BIST.
254
255    Args:
256        query: Search query (e.g., "XU030", "AKBNK", "gold")
257        limit: Maximum results
258
259    Returns:
260        List of VIOP contract symbol strings
261
262    Examples:
263        >>> bp.search_viop("XU030")
264        ['XU030D', 'XU030DG2026', 'XU030DJ2026', ...]
265
266        >>> bp.search_viop("gold")
267        ['XAUTRYD', 'XAUTRYG2026', ...]
268    """
269    return search(query, type="futures", exchange="BIST", limit=limit)

Search VIOP (Turkish derivatives) symbols.

Searches for futures and options contracts on BIST.

Args: query: Search query (e.g., "XU030", "AKBNK", "gold") limit: Maximum results

Returns: List of VIOP contract symbol strings

Examples:

bp.search_viop("XU030") ['XU030D', 'XU030DG2026', 'XU030DJ2026', ...]

>>> bp.search_viop("gold")
['XAUTRYD', 'XAUTRYG2026', ...]
def viop_contracts(base_symbol: str, full_info: bool = False) -> list[str] | list[dict]:
272def viop_contracts(
273    base_symbol: str,
274    full_info: bool = False,
275) -> list[str] | list[dict]:
276    """Get available VIOP contracts for a base symbol.
277
278    Queries TradingView to find all active contracts (expiry months)
279    for a given futures base symbol.
280
281    Args:
282        base_symbol: Base futures symbol (e.g., "XU030D", "XAUTRYD", "USDTRYD")
283                    Can be with or without 'D' suffix.
284        full_info: If True, return full contract info dicts
285
286    Returns:
287        If full_info=False (default):
288            List of contract symbol strings: ['XU030DG2026', 'XU030DJ2026']
289
290        If full_info=True:
291            List of contract dicts with month/year info
292
293    Note:
294        Contract month codes:
295        F=Jan, G=Feb, H=Mar, J=Apr, K=May, M=Jun,
296        N=Jul, Q=Aug, U=Sep, V=Oct, X=Nov, Z=Dec
297
298    Examples:
299        >>> import borsapy as bp
300
301        >>> # Get BIST30 futures contracts
302        >>> bp.viop_contracts("XU030D")
303        ['XU030DG2026', 'XU030DJ2026']
304
305        >>> # Get gold TRY futures
306        >>> bp.viop_contracts("XAUTRYD")
307        ['XAUTRYG2026', 'XAUTRYJ2026']
308
309        >>> # Get full contract info
310        >>> bp.viop_contracts("XU030D", full_info=True)
311        [
312            {'symbol': 'XU030DG2026', 'month_code': 'G', 'year': '2026', ...},
313            {'symbol': 'XU030DJ2026', 'month_code': 'J', 'year': '2026', ...},
314        ]
315
316    See Also:
317        search_viop: Search for VIOP symbols by keyword
318    """
319    from borsapy._providers.tradingview_search import get_search_provider
320
321    provider = get_search_provider()
322    contracts = provider.get_viop_contracts(base_symbol)
323
324    if full_info:
325        return contracts
326    else:
327        # Filter out continuous contracts (they don't work with streaming)
328        return [c["symbol"] for c in contracts if not c.get("is_continuous", False)]

Get available VIOP contracts for a base symbol.

Queries TradingView to find all active contracts (expiry months) for a given futures base symbol.

Args: base_symbol: Base futures symbol (e.g., "XU030D", "XAUTRYD", "USDTRYD") Can be with or without 'D' suffix. full_info: If True, return full contract info dicts

Returns: If full_info=False (default): List of contract symbol strings: ['XU030DG2026', 'XU030DJ2026']

If full_info=True:
    List of contract dicts with month/year info

Note: Contract month codes: F=Jan, G=Feb, H=Mar, J=Apr, K=May, M=Jun, N=Jul, Q=Aug, U=Sep, V=Oct, X=Nov, Z=Dec

Examples:

import borsapy as bp

>>> # Get BIST30 futures contracts
>>> bp.viop_contracts("XU030D")
['XU030DG2026', 'XU030DJ2026']

>>> # Get gold TRY futures
>>> bp.viop_contracts("XAUTRYD")
['XAUTRYG2026', 'XAUTRYJ2026']

>>> # Get full contract info
>>> bp.viop_contracts("XU030D", full_info=True)
[
    {'symbol': 'XU030DG2026', 'month_code': 'G', 'year': '2026', ...},
    {'symbol': 'XU030DJ2026', 'month_code': 'J', 'year': '2026', ...},
]

See Also: search_viop: Search for VIOP symbols by keyword

def banks() -> list[str]:
44def banks() -> list[str]:
45    """
46    Get list of supported banks for exchange rates.
47
48    Returns:
49        List of bank codes.
50
51    Examples:
52        >>> import borsapy as bp
53        >>> bp.banks()
54        ['akbank', 'albaraka', 'alternatifbank', 'anadolubank', ...]
55    """
56    return get_dovizcom_provider().get_banks()

Get list of supported banks for exchange rates.

Returns: List of bank codes.

Examples:

import borsapy as bp bp.banks() ['akbank', 'albaraka', 'alternatifbank', 'anadolubank', ...]

def metal_institutions() -> list[str]:
59def metal_institutions() -> list[str]:
60    """
61    Get list of supported precious metal assets for institution rates.
62
63    Returns:
64        List of asset codes that support institution_rates.
65
66    Examples:
67        >>> import borsapy as bp
68        >>> bp.metal_institutions()
69        ['gram-altin', 'gram-gumus', 'gram-platin', 'ons-altin']
70    """
71    return get_dovizcom_provider().get_metal_institutions()

Get list of supported precious metal assets for institution rates.

Returns: List of asset codes that support institution_rates.

Examples:

import borsapy as bp bp.metal_institutions() ['gram-altin', 'gram-gumus', 'gram-platin', 'ons-altin']

def crypto_pairs(quote: str = 'TRY') -> list[str]:
154def crypto_pairs(quote: str = "TRY") -> list[str]:
155    """
156    Get list of available cryptocurrency trading pairs.
157
158    Args:
159        quote: Quote currency filter (TRY, USDT, BTC)
160
161    Returns:
162        List of available trading pair symbols.
163
164    Examples:
165        >>> import borsapy as bp
166        >>> bp.crypto_pairs()
167        ['BTCTRY', 'ETHTRY', 'XRPTRY', ...]
168        >>> bp.crypto_pairs("USDT")
169        ['BTCUSDT', 'ETHUSDT', ...]
170    """
171    provider = get_btcturk_provider()
172    return provider.get_pairs(quote)

Get list of available cryptocurrency trading pairs.

Args: quote: Quote currency filter (TRY, USDT, BTC)

Returns: List of available trading pair symbols.

Examples:

import borsapy as bp bp.crypto_pairs() ['BTCTRY', 'ETHTRY', 'XRPTRY', ...] bp.crypto_pairs("USDT") ['BTCUSDT', 'ETHUSDT', ...]

def search_funds(query: str, limit: int = 20) -> list[dict[str, typing.Any]]:
581def search_funds(query: str, limit: int = 20) -> list[dict[str, Any]]:
582    """
583    Search for funds by name or code.
584
585    Args:
586        query: Search query (fund code or name)
587        limit: Maximum number of results
588
589    Returns:
590        List of matching funds with fund_code, name, fund_type, return_1y.
591
592    Examples:
593        >>> import borsapy as bp
594        >>> bp.search_funds("ak portföy")
595        [{'fund_code': 'AAK', 'name': 'Ak Portföy...', ...}, ...]
596        >>> bp.search_funds("TTE")
597        [{'fund_code': 'TTE', 'name': 'Türkiye...', ...}]
598    """
599    provider = get_tefas_provider()
600    return provider.search(query, limit)

Search for funds by name or code.

Args: query: Search query (fund code or name) limit: Maximum number of results

Returns: List of matching funds with fund_code, name, fund_type, return_1y.

Examples:

import borsapy as bp bp.search_funds("ak portföy") [{'fund_code': 'AAK', 'name': 'Ak Portföy...', ...}, ...] bp.search_funds("TTE") [{'fund_code': 'TTE', 'name': 'Türkiye...', ...}]

def screen_funds( fund_type: str = 'YAT', founder: str | None = None, min_return_1m: float | None = None, min_return_3m: float | None = None, min_return_6m: float | None = None, min_return_ytd: float | None = None, min_return_1y: float | None = None, min_return_3y: float | None = None, limit: int = 50) -> pandas.core.frame.DataFrame:
603def screen_funds(
604    fund_type: str = "YAT",
605    founder: str | None = None,
606    min_return_1m: float | None = None,
607    min_return_3m: float | None = None,
608    min_return_6m: float | None = None,
609    min_return_ytd: float | None = None,
610    min_return_1y: float | None = None,
611    min_return_3y: float | None = None,
612    limit: int = 50,
613) -> pd.DataFrame:
614    """
615    Screen funds based on fund type and return criteria.
616
617    Args:
618        fund_type: Fund type filter:
619            - "YAT": Investment Funds (Yatırım Fonları) - default
620            - "EMK": Pension Funds (Emeklilik Fonları)
621        founder: Filter by fund management company code (e.g., "AKP", "GPY", "ISP")
622        min_return_1m: Minimum 1-month return (%)
623        min_return_3m: Minimum 3-month return (%)
624        min_return_6m: Minimum 6-month return (%)
625        min_return_ytd: Minimum year-to-date return (%)
626        min_return_1y: Minimum 1-year return (%)
627        min_return_3y: Minimum 3-year return (%)
628        limit: Maximum number of results (default: 50)
629
630    Returns:
631        DataFrame with funds matching the criteria, sorted by 1-year return.
632
633    Examples:
634        >>> import borsapy as bp
635        >>> bp.screen_funds(fund_type="EMK")  # All pension funds
636           fund_code                    name  return_1y  ...
637
638        >>> bp.screen_funds(min_return_1y=50)  # Funds with >50% 1Y return
639           fund_code                    name  return_1y  ...
640
641        >>> bp.screen_funds(fund_type="EMK", min_return_ytd=20)
642           fund_code                    name  return_ytd  ...
643    """
644    provider = get_tefas_provider()
645    results = provider.screen_funds(
646        fund_type=fund_type,
647        founder=founder,
648        min_return_1m=min_return_1m,
649        min_return_3m=min_return_3m,
650        min_return_6m=min_return_6m,
651        min_return_ytd=min_return_ytd,
652        min_return_1y=min_return_1y,
653        min_return_3y=min_return_3y,
654        limit=limit,
655    )
656
657    if not results:
658        return pd.DataFrame(columns=["fund_code", "name", "fund_type", "return_1y"])
659
660    return pd.DataFrame(results)

Screen funds based on fund type and return criteria.

Args: fund_type: Fund type filter: - "YAT": Investment Funds (Yatırım Fonları) - default - "EMK": Pension Funds (Emeklilik Fonları) founder: Filter by fund management company code (e.g., "AKP", "GPY", "ISP") min_return_1m: Minimum 1-month return (%) min_return_3m: Minimum 3-month return (%) min_return_6m: Minimum 6-month return (%) min_return_ytd: Minimum year-to-date return (%) min_return_1y: Minimum 1-year return (%) min_return_3y: Minimum 3-year return (%) limit: Maximum number of results (default: 50)

Returns: DataFrame with funds matching the criteria, sorted by 1-year return.

Examples:

import borsapy as bp bp.screen_funds(fund_type="EMK") # All pension funds fund_code name return_1y ...

>>> bp.screen_funds(min_return_1y=50)  # Funds with >50% 1Y return
   fund_code                    name  return_1y  ...

>>> bp.screen_funds(fund_type="EMK", min_return_ytd=20)
   fund_code                    name  return_ytd  ...
def compare_funds(fund_codes: list[str]) -> dict[str, typing.Any]:
663def compare_funds(fund_codes: list[str]) -> dict[str, Any]:
664    """
665    Compare multiple funds side by side.
666
667    Args:
668        fund_codes: List of TEFAS fund codes to compare (max 10)
669
670    Returns:
671        Dictionary with:
672        - funds: List of fund details with performance metrics
673        - rankings: Ranking by different criteria (by_return_1y, by_return_ytd, by_size, by_risk_asc)
674        - summary: Aggregate statistics (avg_return_1y, best/worst returns, total_size)
675
676    Examples:
677        >>> import borsapy as bp
678        >>> result = bp.compare_funds(["AAK", "TTE", "YAF"])
679        >>> result['rankings']['by_return_1y']
680        ['TTE', 'YAF', 'AAK']
681
682        >>> result['summary']
683        {'fund_count': 3, 'avg_return_1y': 45.2, 'best_return_1y': 72.1, ...}
684
685        >>> for fund in result['funds']:
686        ...     print(f"{fund['fund_code']}: {fund['return_1y']}%")
687        AAK: 32.5%
688        TTE: 72.1%
689        YAF: 31.0%
690    """
691    provider = get_tefas_provider()
692    return provider.compare_funds(fund_codes)

Compare multiple funds side by side.

Args: fund_codes: List of TEFAS fund codes to compare (max 10)

Returns: Dictionary with: - funds: List of fund details with performance metrics - rankings: Ranking by different criteria (by_return_1y, by_return_ytd, by_size, by_risk_asc) - summary: Aggregate statistics (avg_return_1y, best/worst returns, total_size)

Examples:

import borsapy as bp result = bp.compare_funds(["AAK", "TTE", "YAF"]) result['rankings']['by_return_1y'] ['TTE', 'YAF', 'AAK']

>>> result['summary']
{'fund_count': 3, 'avg_return_1y': 45.2, 'best_return_1y': 72.1, ...}

>>> for fund in result['funds']:
...     print(f"{fund['fund_code']}: {fund['return_1y']}%")
AAK: 32.5%
TTE: 72.1%
YAF: 31.0%
def management_fees( fund_type: str = 'YAT', founder: str | None = None) -> pandas.core.frame.DataFrame:
695def management_fees(
696    fund_type: str = "YAT",
697    founder: str | None = None,
698) -> pd.DataFrame:
699    """
700    Get management fee data for all funds.
701
702    Args:
703        fund_type: Fund type filter:
704            - "YAT": Investment Funds (Yatırım Fonları) - default
705            - "EMK": Pension Funds (Emeklilik Fonları)
706        founder: Filter by founder company code (e.g., "AKP", "GPY")
707
708    Returns:
709        DataFrame with columns: fund_code, name, fund_category, founder_code,
710        applied_fee, prospectus_fee, max_expense_ratio, annual_return.
711
712    Examples:
713        >>> import borsapy as bp
714        >>> df = bp.management_fees()
715        >>> df = bp.management_fees(fund_type="EMK")
716        >>> df = bp.management_fees(founder="AKP")
717    """
718    provider = get_tefas_provider()
719    results = provider.get_management_fees(fund_type=fund_type, founder=founder)
720
721    if not results:
722        return pd.DataFrame(columns=[
723            "fund_code", "name", "fund_category", "founder_code",
724            "applied_fee", "prospectus_fee", "max_expense_ratio", "annual_return",
725        ])
726
727    return pd.DataFrame(results)

Get management fee data for all funds.

Args: fund_type: Fund type filter: - "YAT": Investment Funds (Yatırım Fonları) - default - "EMK": Pension Funds (Emeklilik Fonları) founder: Filter by founder company code (e.g., "AKP", "GPY")

Returns: DataFrame with columns: fund_code, name, fund_category, founder_code, applied_fee, prospectus_fee, max_expense_ratio, annual_return.

Examples:

import borsapy as bp df = bp.management_fees() df = bp.management_fees(fund_type="EMK") df = bp.management_fees(founder="AKP")

def download( tickers: str | list[str], period: str = '1mo', interval: str = '1d', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None, group_by: str = 'column', progress: bool = True) -> pandas.core.frame.DataFrame:
104def download(
105    tickers: str | list[str],
106    period: str = "1mo",
107    interval: str = "1d",
108    start: datetime | str | None = None,
109    end: datetime | str | None = None,
110    group_by: str = "column",
111    progress: bool = True,
112) -> pd.DataFrame:
113    """
114    Download historical data for multiple tickers.
115
116    Similar to yfinance.download(), this function fetches OHLCV data
117    for multiple stocks and returns a DataFrame with multi-level columns.
118
119    Args:
120        tickers: Space-separated string or list of symbols.
121                 Example: "THYAO GARAN AKBNK" or ["THYAO", "GARAN"]
122        period: Data period. Valid values:
123                1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max.
124                Ignored if start is provided.
125        interval: Data interval. Valid values:
126                  1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo.
127        start: Start date (string YYYY-MM-DD or datetime).
128        end: End date (string YYYY-MM-DD or datetime). Defaults to today.
129        group_by: How to group the output columns:
130                  - 'column': MultiIndex (Price, Symbol) - default
131                  - 'ticker': MultiIndex (Symbol, Price)
132        progress: Show progress (not implemented, for yfinance compatibility).
133
134    Returns:
135        DataFrame with OHLCV data.
136        - If single ticker: Simple columns (Open, High, Low, Close, Volume)
137        - If multiple tickers: MultiIndex columns based on group_by
138
139    Examples:
140        >>> import borsapy as bp
141
142        # Single ticker (returns simple DataFrame)
143        >>> bp.download("THYAO", period="1mo")
144                         Open    High     Low   Close    Volume
145        Date
146        2024-12-01    265.00  268.00  264.00  267.50  12345678
147
148        # Multiple tickers (returns MultiIndex DataFrame)
149        >>> bp.download(["THYAO", "GARAN"], period="1mo")
150                           Open                 High          ...
151                          THYAO   GARAN        THYAO   GARAN
152        Date
153        2024-12-01       265.00  45.50        268.00   46.20
154
155        # With date range
156        >>> bp.download("THYAO GARAN AKBNK", start="2024-01-01", end="2024-06-30")
157
158        # Group by ticker
159        >>> bp.download(["THYAO", "GARAN"], group_by="ticker")
160                          THYAO                      GARAN
161                           Open    High  Low Close   Open    High
162        Date
163        2024-12-01       265.00  268.00  ...        45.50   46.20
164    """
165    # Parse symbols
166    if isinstance(tickers, str):
167        symbols = [s.strip().upper() for s in tickers.split() if s.strip()]
168    else:
169        symbols = [s.strip().upper() for s in tickers if s.strip()]
170
171    if not symbols:
172        raise ValueError("No symbols provided")
173
174    # Parse dates
175    start_dt = _parse_date(start) if start else None
176    end_dt = _parse_date(end) if end else None
177
178    provider = get_tradingview_provider()
179
180    # Fetch data for each symbol
181    data_frames: dict[str, pd.DataFrame] = {}
182    for symbol in symbols:
183        try:
184            df = provider.get_history(
185                symbol=symbol,
186                period=period,
187                interval=interval,
188                start=start_dt,
189                end=end_dt,
190            )
191            if not df.empty:
192                data_frames[symbol] = df
193        except Exception:
194            # Skip failed symbols silently (yfinance behavior)
195            continue
196
197    if not data_frames:
198        return pd.DataFrame()
199
200    # Single ticker - return simple DataFrame
201    if len(symbols) == 1 and len(data_frames) == 1:
202        return list(data_frames.values())[0]
203
204    # Multiple tickers - create MultiIndex DataFrame
205    if group_by == "ticker":
206        # Group by ticker first: (THYAO, Open), (THYAO, High), ...
207        result = pd.concat(data_frames, axis=1)
208        # result columns are already (symbol, price)
209    else:
210        # Group by column first: (Open, THYAO), (Open, GARAN), ...
211        result = pd.concat(data_frames, axis=1)
212        # Swap levels to get (price, symbol)
213        result = result.swaplevel(axis=1)
214        result = result.sort_index(axis=1, level=0)
215
216    return result

Download historical data for multiple tickers.

Similar to yfinance.download(), this function fetches OHLCV data for multiple stocks and returns a DataFrame with multi-level columns.

Args: tickers: Space-separated string or list of symbols. Example: "THYAO GARAN AKBNK" or ["THYAO", "GARAN"] period: Data period. Valid values: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max. Ignored if start is provided. interval: Data interval. Valid values: 1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo. start: Start date (string YYYY-MM-DD or datetime). end: End date (string YYYY-MM-DD or datetime). Defaults to today. group_by: How to group the output columns: - 'column': MultiIndex (Price, Symbol) - default - 'ticker': MultiIndex (Symbol, Price) progress: Show progress (not implemented, for yfinance compatibility).

Returns: DataFrame with OHLCV data. - If single ticker: Simple columns (Open, High, Low, Close, Volume) - If multiple tickers: MultiIndex columns based on group_by

Examples:

import borsapy as bp

# Single ticker (returns simple DataFrame)
>>> bp.download("THYAO", period="1mo")
                 Open    High     Low   Close    Volume
Date
2024-12-01    265.00  268.00  264.00  267.50  12345678

# Multiple tickers (returns MultiIndex DataFrame)
>>> bp.download(["THYAO", "GARAN"], period="1mo")
                   Open                 High          ...
                  THYAO   GARAN        THYAO   GARAN
Date
2024-12-01       265.00  45.50        268.00   46.20

# With date range
>>> bp.download("THYAO GARAN AKBNK", start="2024-01-01", end="2024-06-30")

# Group by ticker
>>> bp.download(["THYAO", "GARAN"], group_by="ticker")
                  THYAO                      GARAN
                   Open    High  Low Close   Open    High
Date
2024-12-01       265.00  268.00  ...        45.50   46.20
def index(symbol: str) -> Index:
307def index(symbol: str) -> Index:
308    """
309    Get an Index object for the given symbol.
310
311    This is a convenience function that creates an Index object.
312
313    Args:
314        symbol: Index symbol (e.g., "XU100", "XBANK").
315
316    Returns:
317        Index object.
318
319    Examples:
320        >>> import borsapy as bp
321        >>> xu100 = bp.index("XU100")
322        >>> xu100.history(period="1mo")
323    """
324    return Index(symbol)

Get an Index object for the given symbol.

This is a convenience function that creates an Index object.

Args: symbol: Index symbol (e.g., "XU100", "XBANK").

Returns: Index object.

Examples:

import borsapy as bp xu100 = bp.index("XU100") xu100.history(period="1mo")

def indices(detailed: bool = False) -> list[str] | list[dict[str, typing.Any]]:
250def indices(detailed: bool = False) -> list[str] | list[dict[str, Any]]:
251    """
252    Get list of available market indices.
253
254    Args:
255        detailed: If True, return list of dicts with symbol, name, and count.
256                  If False (default), return just the symbol list.
257
258    Returns:
259        List of index symbols, or list of dicts if detailed=True.
260
261    Examples:
262        >>> import borsapy as bp
263        >>> bp.indices()
264        ['XU100', 'XU050', 'XU030', 'XBANK', 'XUSIN', ...]
265        >>> bp.indices(detailed=True)
266        [{'symbol': 'XU100', 'name': 'BIST 100', 'count': 100}, ...]
267    """
268    if not detailed:
269        return list(INDICES.keys())
270
271    # Get component counts from provider
272    provider = get_bist_index_provider()
273    available = provider.get_available_indices()
274
275    # Create lookup for counts
276    count_map = {item["symbol"]: item["count"] for item in available}
277
278    result = []
279    for symbol, name in INDICES.items():
280        result.append({
281            "symbol": symbol,
282            "name": name,
283            "count": count_map.get(symbol, 0),
284        })
285    return result

Get list of available market indices.

Args: detailed: If True, return list of dicts with symbol, name, and count. If False (default), return just the symbol list.

Returns: List of index symbols, or list of dicts if detailed=True.

Examples:

import borsapy as bp bp.indices() ['XU100', 'XU050', 'XU030', 'XBANK', 'XUSIN', ...] bp.indices(detailed=True) [{'symbol': 'XU100', 'name': 'BIST 100', 'count': 100}, ...]

def all_indices() -> list[dict[str, typing.Any]]:
288def all_indices() -> list[dict[str, Any]]:
289    """
290    Get all indices from BIST with component counts.
291
292    This returns all 79 indices available in the BIST data,
293    not just the commonly used ones in indices().
294
295    Returns:
296        List of dicts with 'symbol', 'name', and 'count' keys.
297
298    Examples:
299        >>> import borsapy as bp
300        >>> bp.all_indices()
301        [{'symbol': 'X030C', 'name': 'BIST 30 Capped', 'count': 30}, ...]
302    """
303    provider = get_bist_index_provider()
304    return provider.get_available_indices()

Get all indices from BIST with component counts.

This returns all 79 indices available in the BIST data, not just the commonly used ones in indices().

Returns: List of dicts with 'symbol', 'name', and 'count' keys.

Examples:

import borsapy as bp bp.all_indices() [{'symbol': 'X030C', 'name': 'BIST 30 Capped', 'count': 30}, ...]

def bonds() -> pandas.core.frame.DataFrame:
110def bonds() -> pd.DataFrame:
111    """
112    Get all Turkish government bond yields.
113
114    Returns:
115        DataFrame with columns: name, maturity, yield, change, change_pct.
116
117    Examples:
118        >>> import borsapy as bp
119        >>> bp.bonds()
120                      name maturity   yield  change  change_pct
121        0  2 Yıllık Tahvil       2Y   26.42    0.40        1.54
122        1  5 Yıllık Tahvil       5Y   27.15    0.35        1.31
123        2 10 Yıllık Tahvil      10Y   28.03    0.42        1.52
124    """
125    provider = get_tahvil_provider()
126    data = provider.get_bond_yields()
127
128    if not data:
129        return pd.DataFrame(columns=["name", "maturity", "yield", "change", "change_pct"])
130
131    df = pd.DataFrame(data)
132
133    # Select and rename columns
134    columns = {
135        "name": "name",
136        "maturity": "maturity",
137        "yield": "yield",
138        "change": "change",
139        "change_pct": "change_pct",
140    }
141
142    df = df[[c for c in columns.keys() if c in df.columns]]
143    df = df.rename(columns=columns)
144
145    return df

Get all Turkish government bond yields.

Returns: DataFrame with columns: name, maturity, yield, change, change_pct.

Examples:

import borsapy as bp bp.bonds() name maturity yield change change_pct 0 2 Yıllık Tahvil 2Y 26.42 0.40 1.54 1 5 Yıllık Tahvil 5Y 27.15 0.35 1.31 2 10 Yıllık Tahvil 10Y 28.03 0.42 1.52

def risk_free_rate() -> float | None:
148def risk_free_rate() -> float | None:
149    """
150    Get the risk-free rate for Turkish market (10Y bond yield).
151
152    Returns:
153        10-year government bond yield as decimal.
154        Useful for CAPM and DCF calculations.
155
156    Examples:
157        >>> import borsapy as bp
158        >>> rfr = bp.risk_free_rate()
159        >>> rfr
160        0.2803
161    """
162    provider = get_tahvil_provider()
163    return provider.get_10y_yield()

Get the risk-free rate for Turkish market (10Y bond yield).

Returns: 10-year government bond yield as decimal. Useful for CAPM and DCF calculations.

Examples:

import borsapy as bp rfr = bp.risk_free_rate() rfr 0.2803

def eurobonds(currency: str | None = None) -> pandas.core.frame.DataFrame:
238def eurobonds(currency: str | None = None) -> pd.DataFrame:
239    """Get all Turkish sovereign Eurobonds as DataFrame.
240
241    Args:
242        currency: Optional filter by currency ("USD" or "EUR").
243
244    Returns:
245        DataFrame with columns: isin, maturity, days_to_maturity,
246        currency, bid_price, bid_yield, ask_price, ask_yield.
247
248    Examples:
249        >>> import borsapy as bp
250        >>> bp.eurobonds()                # All Eurobonds
251        >>> bp.eurobonds(currency="USD")  # USD bonds only
252        >>> bp.eurobonds(currency="EUR")  # EUR bonds only
253    """
254    provider = get_eurobond_provider()
255    data = provider.get_eurobonds(currency=currency)
256
257    if not data:
258        return pd.DataFrame(
259            columns=[
260                "isin",
261                "maturity",
262                "days_to_maturity",
263                "currency",
264                "bid_price",
265                "bid_yield",
266                "ask_price",
267                "ask_yield",
268            ]
269        )
270
271    df = pd.DataFrame(data)
272    df = df.sort_values("maturity")
273    return df

Get all Turkish sovereign Eurobonds as DataFrame.

Args: currency: Optional filter by currency ("USD" or "EUR").

Returns: DataFrame with columns: isin, maturity, days_to_maturity, currency, bid_price, bid_yield, ask_price, ask_yield.

Examples:

import borsapy as bp bp.eurobonds() # All Eurobonds bp.eurobonds(currency="USD") # USD bonds only bp.eurobonds(currency="EUR") # EUR bonds only

def policy_rate() -> float | None:
190def policy_rate() -> float | None:
191    """Get current TCMB policy rate (1-week repo).
192
193    This is a shortcut function for quick access to the
194    main policy interest rate.
195
196    Returns:
197        Policy rate as percentage (e.g., 38.0 for 38%).
198
199    Example:
200        >>> import borsapy as bp
201        >>> bp.policy_rate()
202        38.0
203    """
204    return TCMB().policy_rate

Get current TCMB policy rate (1-week repo).

This is a shortcut function for quick access to the main policy interest rate.

Returns: Policy rate as percentage (e.g., 38.0 for 38%).

Example:

import borsapy as bp bp.policy_rate() 38.0

def evds_categories() -> pandas.core.frame.DataFrame:
638def evds_categories() -> pd.DataFrame:
639    """Shortcut: ``EVDS().categories``."""
640    return EVDS().categories

Shortcut: EVDS().categories.

def evds_series( code: str, period: str = '1y', start: str | datetime.date | datetime.datetime | None = None, end: str | datetime.date | datetime.datetime | None = None, frequency: str | int | None = None, aggregation: str = 'avg', formula: str = 'level', decimals: int = 2, decimal_separator: str = '.') -> pandas.core.frame.DataFrame:
648def evds_series(
649    code: str,
650    period: str = "1y",
651    start: str | date | datetime | None = None,
652    end: str | date | datetime | None = None,
653    frequency: str | int | None = None,
654    aggregation: str = "avg",
655    formula: str = "level",
656    decimals: int = 2,
657    decimal_separator: str = ".",
658) -> pd.DataFrame:
659    """Fetch a single series's history.
660
661    Equivalent to::
662
663        EVDSSeries(code).history(period=..., start=..., end=..., ...)
664    """
665    return EVDSSeries(code).history(
666        period=period,
667        start=start,
668        end=end,
669        frequency=frequency,
670        aggregation=aggregation,
671        formula=formula,
672        decimals=decimals,
673        decimal_separator=decimal_separator,
674    )

Fetch a single series's history.

Equivalent to::

EVDSSeries(code).history(period=..., start=..., end=..., ...)
def evds_download( codes: list[str] | str, period: str = '1y', start: str | datetime.date | datetime.datetime | None = None, end: str | datetime.date | datetime.datetime | None = None, frequency: str | int = 'monthly', aggregation: str | list[str] = 'avg', formula: str | list[str] = 'level', decimals: int = 2, decimal_separator: str = '.') -> pandas.core.frame.DataFrame:
677def evds_download(
678    codes: list[str] | str,
679    period: str = "1y",
680    start: str | date | datetime | None = None,
681    end: str | date | datetime | None = None,
682    frequency: str | int = "monthly",
683    aggregation: str | list[str] = "avg",
684    formula: str | list[str] = "level",
685    decimals: int = 2,
686    decimal_separator: str = ".",
687) -> pd.DataFrame:
688    """Fetch multiple series in a single POST, returning a wide DataFrame.
689
690    Mirrors :func:`borsapy.download` for stocks.
691
692    Args:
693        codes: List or single string of dot-separated series codes
694            (e.g. ``["TP.DK.USD.A", "TP.DK.EUR.A"]``).
695        period / start / end: yfinance-style window selection.
696        frequency: snake_case key (``"daily"``, ``"monthly"`` ...) or 1..8.
697        aggregation: scalar (broadcast) or per-series list.
698        formula: scalar or per-series list.
699
700    Returns:
701        Wide DataFrame indexed by Date, one column per series (user-facing
702        dot-separated codes).
703    """
704    if isinstance(codes, str):
705        codes = [codes]
706    if not codes:
707        raise ValueError("at least one series code is required")
708    start_str, end_str = _resolve_window(period, start, end)
709    provider = get_evds_provider()
710    payload = provider.get_series_data(
711        codes,
712        start=start_str,
713        end=end_str,
714        frequency=frequency,
715        aggregation=aggregation,
716        formula=formula,
717        decimals=decimals,
718        decimal_separator=decimal_separator,
719    )
720    df = _frame_from_payload(payload, codes)
721    # If only a single series, normalize to user-facing column.
722    if len(codes) == 1 and "Value" in df.columns:
723        df = df.rename(columns={"Value": codes[0]})
724    return df

Fetch multiple series in a single POST, returning a wide DataFrame.

Mirrors borsapy.download() for stocks.

Args: codes: List or single string of dot-separated series codes (e.g. ["TP.DK.USD.A", "TP.DK.EUR.A"]). period / start / end: yfinance-style window selection. frequency: snake_case key ("daily", "monthly" ...) or 1..8. aggregation: scalar (broadcast) or per-series list. formula: scalar or per-series list.

Returns: Wide DataFrame indexed by Date, one column per series (user-facing dot-separated codes).

def set_evds_key(key: str) -> None:
64def set_evds_key(key: str) -> None:
65    """Configure the EVDS API key used by the official REST endpoints.
66
67    Args:
68        key: The API key copied from https://evds3.tcmb.gov.tr → BENİM SAYFAM.
69
70    Once set, :class:`EVDSProvider` routes :meth:`get_series_data` (and the
71    other time-series fetches) through the official REST API, which is far
72    more reliable than the SPA-internal ``POST /fe`` path.
73
74    The key may also be provided via the ``EVDS_API_KEY`` environment
75    variable.
76    """
77    global _api_key
78    if not key or not isinstance(key, str):
79        raise ValueError("EVDS API key must be a non-empty string")
80    _api_key = key.strip()
81    # Reset the singleton so the next call rebuilds the session with the new
82    # auth headers.
83    global _provider
84    _provider = None

Configure the EVDS API key used by the official REST endpoints.

Args: key: The API key copied from https://evds3.tcmb.gov.tr → BENİM SAYFAM.

Once set, EVDSProvider routes get_series_data() (and the other time-series fetches) through the official REST API, which is far more reliable than the SPA-internal POST /fe path.

The key may also be provided via the EVDS_API_KEY environment variable.

def clear_evds_key() -> None:
87def clear_evds_key() -> None:
88    """Forget the configured EVDS API key (revert to anonymous mode)."""
89    global _api_key, _provider
90    _api_key = None
91    _provider = None

Forget the configured EVDS API key (revert to anonymous mode).

def get_evds_key() -> str | None:
 94def get_evds_key() -> str | None:
 95    """Return the configured EVDS API key, or ``None`` if not set.
 96
 97    Falls back to ``EVDS_API_KEY`` environment variable when no key has been
 98    set programmatically.
 99    """
100    if _api_key:
101        return _api_key
102    env = os.environ.get("EVDS_API_KEY", "").strip()
103    return env or None

Return the configured EVDS API key, or None if not set.

Falls back to EVDS_API_KEY environment variable when no key has been set programmatically.

def economic_calendar( period: str = '1w', country: str | list[str] | None = None, importance: str | None = None) -> pandas.core.frame.DataFrame:
250def economic_calendar(
251    period: str = "1w",
252    country: str | list[str] | None = None,
253    importance: str | None = None,
254) -> pd.DataFrame:
255    """
256    Get economic calendar events (convenience function).
257
258    Args:
259        period: Time period (1d, 1w, 2w, 1mo). Defaults to 1w.
260        country: Country code(s) to filter by. Defaults to ['TR', 'US'].
261        importance: Filter by importance level ('low', 'mid', 'high').
262
263    Returns:
264        DataFrame with economic events.
265
266    Examples:
267        >>> import borsapy as bp
268        >>> bp.economic_calendar()  # This week, TR + US
269        >>> bp.economic_calendar(country="TR", importance="high")
270    """
271    cal = EconomicCalendar()
272    return cal.events(period=period, country=country, importance=importance)

Get economic calendar events (convenience function).

Args: period: Time period (1d, 1w, 2w, 1mo). Defaults to 1w. country: Country code(s) to filter by. Defaults to ['TR', 'US']. importance: Filter by importance level ('low', 'mid', 'high').

Returns: DataFrame with economic events.

Examples:

import borsapy as bp bp.economic_calendar() # This week, TR + US bp.economic_calendar(country="TR", importance="high")

def screen_stocks( template: str | None = None, sector: str | None = None, index: str | None = None, recommendation: str | None = None, market_cap_min: float | None = None, market_cap_max: float | None = None, pe_min: float | None = None, pe_max: float | None = None, pb_min: float | None = None, pb_max: float | None = None, dividend_yield_min: float | None = None, dividend_yield_max: float | None = None, upside_potential_min: float | None = None, upside_potential_max: float | None = None, net_margin_min: float | None = None, net_margin_max: float | None = None, roe_min: float | None = None, roe_max: float | None = None) -> pandas.core.frame.DataFrame:
258def screen_stocks(
259    template: str | None = None,
260    sector: str | None = None,
261    index: str | None = None,
262    recommendation: str | None = None,
263    # Common filters as direct parameters
264    market_cap_min: float | None = None,
265    market_cap_max: float | None = None,
266    pe_min: float | None = None,
267    pe_max: float | None = None,
268    pb_min: float | None = None,
269    pb_max: float | None = None,
270    dividend_yield_min: float | None = None,
271    dividend_yield_max: float | None = None,
272    upside_potential_min: float | None = None,
273    upside_potential_max: float | None = None,
274    net_margin_min: float | None = None,
275    net_margin_max: float | None = None,
276    roe_min: float | None = None,
277    roe_max: float | None = None,
278) -> pd.DataFrame:
279    """
280    Screen BIST stocks based on criteria (convenience function).
281
282    Args:
283        template: Pre-defined template name:
284            - "small_cap": Market cap < $1B
285            - "mid_cap": Market cap $1B-$5B
286            - "large_cap": Market cap > $5B
287            - "high_dividend": Dividend yield > 2%
288            - "high_upside": Positive upside potential
289            - "buy_recommendation": BUY recommendations
290            - "sell_recommendation": SELL recommendations
291            - "high_net_margin": Net margin > 10%
292            - "high_return": Positive weekly return
293        sector: Sector filter (e.g., "Bankacılık").
294        index: Index filter (e.g., "BIST30").
295        recommendation: "AL", "SAT", or "TUT".
296        market_cap_min/max: Market cap in million USD.
297        pe_min/max: P/E ratio.
298        pb_min/max: P/B ratio.
299        dividend_yield_min/max: Dividend yield (%).
300        upside_potential_min/max: Upside potential (%).
301        net_margin_min/max: Net margin (%).
302        roe_min/max: Return on equity (%).
303
304    Returns:
305        DataFrame with matching stocks.
306
307    Examples:
308        >>> import borsapy as bp
309
310        >>> # Using template
311        >>> bp.screen_stocks(template="high_dividend")
312
313        >>> # Custom filters
314        >>> bp.screen_stocks(market_cap_min=1000, pe_max=15)
315
316        >>> # Combined
317        >>> bp.screen_stocks(
318        ...     sector="Bankacılık",
319        ...     dividend_yield_min=3,
320        ...     pe_max=10
321        ... )
322    """
323    screener = Screener()
324
325    # Set sector/index/recommendation
326    if sector:
327        screener.set_sector(sector)
328    if index:
329        screener.set_index(index)
330    if recommendation:
331        screener.set_recommendation(recommendation)
332
333    # Add filters
334    if market_cap_min is not None or market_cap_max is not None:
335        screener.add_filter("market_cap", min=market_cap_min, max=market_cap_max)
336
337    if pe_min is not None or pe_max is not None:
338        screener.add_filter("pe", min=pe_min, max=pe_max)
339
340    if pb_min is not None or pb_max is not None:
341        screener.add_filter("pb", min=pb_min, max=pb_max)
342
343    if dividend_yield_min is not None or dividend_yield_max is not None:
344        screener.add_filter("dividend_yield", min=dividend_yield_min, max=dividend_yield_max)
345
346    if upside_potential_min is not None or upside_potential_max is not None:
347        screener.add_filter("upside_potential", min=upside_potential_min, max=upside_potential_max)
348
349    if net_margin_min is not None or net_margin_max is not None:
350        screener.add_filter("net_margin", min=net_margin_min, max=net_margin_max)
351
352    if roe_min is not None or roe_max is not None:
353        screener.add_filter("roe", min=roe_min, max=roe_max)
354
355    return screener.run(template=template)

Screen BIST stocks based on criteria (convenience function).

Args: template: Pre-defined template name: - "small_cap": Market cap < $1B - "mid_cap": Market cap $1B-$5B - "large_cap": Market cap > $5B - "high_dividend": Dividend yield > 2% - "high_upside": Positive upside potential - "buy_recommendation": BUY recommendations - "sell_recommendation": SELL recommendations - "high_net_margin": Net margin > 10% - "high_return": Positive weekly return sector: Sector filter (e.g., "Bankacılık"). index: Index filter (e.g., "BIST30"). recommendation: "AL", "SAT", or "TUT". market_cap_min/max: Market cap in million USD. pe_min/max: P/E ratio. pb_min/max: P/B ratio. dividend_yield_min/max: Dividend yield (%). upside_potential_min/max: Upside potential (%). net_margin_min/max: Net margin (%). roe_min/max: Return on equity (%).

Returns: DataFrame with matching stocks.

Examples:

import borsapy as bp

>>> # Using template
>>> bp.screen_stocks(template="high_dividend")

>>> # Custom filters
>>> bp.screen_stocks(market_cap_min=1000, pe_max=15)

>>> # Combined
>>> bp.screen_stocks(
...     sector="Bankacılık",
...     dividend_yield_min=3,
...     pe_max=10
... )
def screener_criteria() -> list[dict[str, typing.Any]]:
358def screener_criteria() -> list[dict[str, Any]]:
359    """
360    Get list of available screening criteria.
361
362    Returns:
363        List of criteria with id, name, min, max values.
364
365    Examples:
366        >>> import borsapy as bp
367        >>> bp.screener_criteria()
368        [{'id': '7', 'name': 'Kapanış (TL)', 'min': '1.1', 'max': '14087.5'}, ...]
369    """
370    provider = get_screener_provider()
371    return provider.get_criteria()

Get list of available screening criteria.

Returns: List of criteria with id, name, min, max values.

Examples:

import borsapy as bp bp.screener_criteria() [{'id': '7', 'name': 'Kapanış (TL)', 'min': '1.1', 'max': '14087.5'}, ...]

def sectors() -> list[str]:
374def sectors() -> list[str]:
375    """
376    Get list of available sectors for screening.
377
378    Returns:
379        List of sector names.
380
381    Examples:
382        >>> import borsapy as bp
383        >>> bp.sectors()
384        ['Bankacılık', 'Holding', 'Enerji', ...]
385    """
386    provider = get_screener_provider()
387    data = provider.get_sectors()
388    return [item["name"] for item in data if item.get("name")]

Get list of available sectors for screening.

Returns: List of sector names.

Examples:

import borsapy as bp bp.sectors() ['Bankacılık', 'Holding', 'Enerji', ...]

def stock_indices() -> list[str]:
391def stock_indices() -> list[str]:
392    """
393    Get list of available indices for screening.
394
395    Returns:
396        List of index names.
397
398    Examples:
399        >>> import borsapy as bp
400        >>> bp.stock_indices()
401        ['BIST30', 'BIST100', 'BIST BANKA', ...]
402    """
403    provider = get_screener_provider()
404    data = provider.get_indices()
405    return [item["name"] for item in data if item.get("name")]

Get list of available indices for screening.

Returns: List of index names.

Examples:

import borsapy as bp bp.stock_indices() ['BIST30', 'BIST100', 'BIST BANKA', ...]

class TechnicalScanner:
138class TechnicalScanner:
139    """Scanner for technical analysis conditions using TradingView API.
140
141    Provides a fluent API for building and executing stock scans based on
142    technical indicators. All filtering is done server-side by TradingView
143    for optimal performance.
144
145    Examples:
146        >>> scanner = TechnicalScanner()
147        >>> scanner.set_universe("XU030")
148        >>> scanner.add_condition("rsi < 30", name="oversold")
149        >>> scanner.add_condition("volume > 1M", name="high_vol")
150        >>> results = scanner.run()
151        >>> print(results)
152
153    Supported Timeframes:
154        "1m", "5m", "15m", "30m", "1h", "2h", "4h", "1d", "1W", "1M"
155    """
156
157    def __init__(self) -> None:
158        """Initialize scanner."""
159        self._provider = get_tv_screener_provider()
160        self._symbols: list[str] = []
161        self._conditions: list[str] = []
162        self._condition_names: dict[str, str] = {}  # condition -> name
163        self._interval: str = "1d"
164        self._extra_columns: list[str] = []
165
166    def set_universe(self, universe: str | list[str]) -> TechnicalScanner:
167        """Set the universe of symbols to scan.
168
169        Args:
170            universe: Index symbol (e.g., "XU030", "XU100", "XBANK") or list of stock symbols
171
172        Returns:
173            Self for method chaining
174
175        Examples:
176            >>> scanner.set_universe("XU030")  # BIST 30 components
177            >>> scanner.set_universe(["THYAO", "GARAN", "ASELS"])  # Specific symbols
178        """
179        if isinstance(universe, str):
180            # Check if it's an index
181            if universe.upper().startswith("X"):
182                from borsapy.index import Index
183
184                try:
185                    idx = Index(universe.upper())
186                    self._symbols = idx.component_symbols
187                except Exception:
188                    # Not a valid index, treat as single symbol
189                    self._symbols = [universe.upper()]
190            else:
191                self._symbols = [universe.upper()]
192        else:
193            self._symbols = [s.upper() for s in universe]
194        return self
195
196    def add_symbol(self, symbol: str) -> TechnicalScanner:
197        """Add a single symbol to the universe.
198
199        Args:
200            symbol: Stock symbol to add
201
202        Returns:
203            Self for method chaining
204        """
205        symbol = symbol.upper()
206        if symbol not in self._symbols:
207            self._symbols.append(symbol)
208        return self
209
210    def remove_symbol(self, symbol: str) -> TechnicalScanner:
211        """Remove a symbol from the universe.
212
213        Args:
214            symbol: Stock symbol to remove
215
216        Returns:
217            Self for method chaining
218        """
219        symbol = symbol.upper()
220        if symbol in self._symbols:
221            self._symbols.remove(symbol)
222        return self
223
224    def add_condition(
225        self, condition: str, name: str | None = None
226    ) -> TechnicalScanner:
227        """Add a scanning condition.
228
229        Conditions are combined with AND logic. For OR logic, use the full
230        condition string: "(rsi < 30 or rsi > 70)".
231
232        Args:
233            condition: Condition string (e.g., "rsi < 30", "close > sma_50")
234            name: Optional name for the condition (for reporting)
235
236        Returns:
237            Self for method chaining
238
239        Supported Syntax:
240            - Simple: "rsi < 30", "volume > 1M"
241            - Field comparison: "close > sma_50", "macd > signal"
242            - Compound (AND): "rsi < 30 and volume > 1M"
243            - Crossover: "sma_20 crosses_above sma_50"
244
245        Examples:
246            >>> scanner.add_condition("rsi < 30", name="oversold")
247            >>> scanner.add_condition("volume > 1M", name="high_volume")
248        """
249        # Split by "and" for multiple conditions
250        parts = [c.strip() for c in condition.lower().split(" and ")]
251
252        for part in parts:
253            if part and part not in self._conditions:
254                self._conditions.append(part)
255                cond_name = name if name and len(parts) == 1 else part
256                self._condition_names[part] = cond_name
257
258        return self
259
260    def remove_condition(self, name_or_condition: str) -> TechnicalScanner:
261        """Remove a condition by name or condition string.
262
263        Args:
264            name_or_condition: Condition name or string to remove
265
266        Returns:
267            Self for method chaining
268        """
269        # Try to find by name
270        for cond, cname in list(self._condition_names.items()):
271            if cname == name_or_condition or cond == name_or_condition.lower():
272                self._conditions.remove(cond)
273                del self._condition_names[cond]
274                break
275        return self
276
277    def clear_conditions(self) -> TechnicalScanner:
278        """Clear all conditions.
279
280        Returns:
281            Self for method chaining
282        """
283        self._conditions.clear()
284        self._condition_names.clear()
285        return self
286
287    def set_interval(self, interval: str) -> TechnicalScanner:
288        """Set the data interval/timeframe for indicators.
289
290        Args:
291            interval: Timeframe for indicators:
292                - "1m", "5m", "15m", "30m" (intraday minutes)
293                - "1h", "2h", "4h" (intraday hours)
294                - "1d" (daily, default)
295                - "1W", "1wk" (weekly)
296                - "1M", "1mo" (monthly)
297
298        Returns:
299            Self for method chaining
300        """
301        self._interval = interval
302        return self
303
304    def add_column(self, column: str) -> TechnicalScanner:
305        """Add extra column to retrieve in results.
306
307        Args:
308            column: Column name (e.g., "ema_200", "adx")
309
310        Returns:
311            Self for method chaining
312        """
313        if column not in self._extra_columns:
314            self._extra_columns.append(column)
315        return self
316
317    def run(self, limit: int = 100) -> pd.DataFrame:
318        """Execute the scan and return results.
319
320        Args:
321            limit: Maximum number of results
322
323        Returns:
324            DataFrame with matching symbols and their data.
325            Columns include: symbol, close, volume, change, market_cap,
326            plus any indicator columns used in conditions.
327
328        Raises:
329            ValueError: If no symbols or conditions are set
330        """
331        if not self._symbols:
332            return pd.DataFrame()
333
334        if not self._conditions:
335            return pd.DataFrame()
336
337        # Execute scan via provider
338        df = self._provider.scan(
339            symbols=self._symbols,
340            conditions=self._conditions,
341            columns=self._extra_columns,
342            interval=self._interval,
343            limit=limit,
344        )
345
346        # Add conditions_met column for compatibility
347        if not df.empty:
348            df["conditions_met"] = [list(self._condition_names.values())] * len(df)
349
350        return df
351
352    @property
353    def symbols(self) -> list[str]:
354        """Get current symbol universe."""
355        return self._symbols.copy()
356
357    @property
358    def conditions(self) -> list[str]:
359        """Get current conditions."""
360        return self._conditions.copy()
361
362    # Backward compatibility aliases
363    def set_data_period(self, period: str = "3mo") -> TechnicalScanner:
364        """Deprecated: Period is not used with TradingView API."""
365        import warnings
366
367        warnings.warn(
368            "set_data_period() is deprecated. TradingView API uses real-time data.",
369            DeprecationWarning,
370            stacklevel=2,
371        )
372        return self
373
374    @property
375    def results(self) -> list[ScanResult]:
376        """Deprecated: Use run() which returns DataFrame directly."""
377        return []
378
379    def to_dataframe(self) -> pd.DataFrame:
380        """Deprecated: Use run() which returns DataFrame directly."""
381        return self.run()
382
383    def on_match(self, callback) -> None:
384        """Deprecated: Callbacks not supported with batch API."""
385        import warnings
386
387        warnings.warn(
388            "on_match() is deprecated. Use run() and iterate results.",
389            DeprecationWarning,
390            stacklevel=2,
391        )
392
393    def on_scan_complete(self, callback) -> None:
394        """Deprecated: Callbacks not supported with batch API."""
395        import warnings
396
397        warnings.warn(
398            "on_scan_complete() is deprecated. Use run() directly.",
399            DeprecationWarning,
400            stacklevel=2,
401        )
402
403    def __repr__(self) -> str:
404        return (
405            f"TechnicalScanner(symbols={len(self._symbols)}, "
406            f"conditions={len(self._conditions)}, interval='{self._interval}')"
407        )

Scanner for technical analysis conditions using TradingView API.

Provides a fluent API for building and executing stock scans based on technical indicators. All filtering is done server-side by TradingView for optimal performance.

Examples:

scanner = TechnicalScanner() scanner.set_universe("XU030") scanner.add_condition("rsi < 30", name="oversold") scanner.add_condition("volume > 1M", name="high_vol") results = scanner.run() print(results)

Supported Timeframes: "1m", "5m", "15m", "30m", "1h", "2h", "4h", "1d", "1W", "1M"

TechnicalScanner()
157    def __init__(self) -> None:
158        """Initialize scanner."""
159        self._provider = get_tv_screener_provider()
160        self._symbols: list[str] = []
161        self._conditions: list[str] = []
162        self._condition_names: dict[str, str] = {}  # condition -> name
163        self._interval: str = "1d"
164        self._extra_columns: list[str] = []

Initialize scanner.

def set_universe(self, universe: str | list[str]) -> TechnicalScanner:
166    def set_universe(self, universe: str | list[str]) -> TechnicalScanner:
167        """Set the universe of symbols to scan.
168
169        Args:
170            universe: Index symbol (e.g., "XU030", "XU100", "XBANK") or list of stock symbols
171
172        Returns:
173            Self for method chaining
174
175        Examples:
176            >>> scanner.set_universe("XU030")  # BIST 30 components
177            >>> scanner.set_universe(["THYAO", "GARAN", "ASELS"])  # Specific symbols
178        """
179        if isinstance(universe, str):
180            # Check if it's an index
181            if universe.upper().startswith("X"):
182                from borsapy.index import Index
183
184                try:
185                    idx = Index(universe.upper())
186                    self._symbols = idx.component_symbols
187                except Exception:
188                    # Not a valid index, treat as single symbol
189                    self._symbols = [universe.upper()]
190            else:
191                self._symbols = [universe.upper()]
192        else:
193            self._symbols = [s.upper() for s in universe]
194        return self

Set the universe of symbols to scan.

Args: universe: Index symbol (e.g., "XU030", "XU100", "XBANK") or list of stock symbols

Returns: Self for method chaining

Examples:

scanner.set_universe("XU030") # BIST 30 components scanner.set_universe(["THYAO", "GARAN", "ASELS"]) # Specific symbols

def add_symbol(self, symbol: str) -> TechnicalScanner:
196    def add_symbol(self, symbol: str) -> TechnicalScanner:
197        """Add a single symbol to the universe.
198
199        Args:
200            symbol: Stock symbol to add
201
202        Returns:
203            Self for method chaining
204        """
205        symbol = symbol.upper()
206        if symbol not in self._symbols:
207            self._symbols.append(symbol)
208        return self

Add a single symbol to the universe.

Args: symbol: Stock symbol to add

Returns: Self for method chaining

def remove_symbol(self, symbol: str) -> TechnicalScanner:
210    def remove_symbol(self, symbol: str) -> TechnicalScanner:
211        """Remove a symbol from the universe.
212
213        Args:
214            symbol: Stock symbol to remove
215
216        Returns:
217            Self for method chaining
218        """
219        symbol = symbol.upper()
220        if symbol in self._symbols:
221            self._symbols.remove(symbol)
222        return self

Remove a symbol from the universe.

Args: symbol: Stock symbol to remove

Returns: Self for method chaining

def add_condition( self, condition: str, name: str | None = None) -> TechnicalScanner:
224    def add_condition(
225        self, condition: str, name: str | None = None
226    ) -> TechnicalScanner:
227        """Add a scanning condition.
228
229        Conditions are combined with AND logic. For OR logic, use the full
230        condition string: "(rsi < 30 or rsi > 70)".
231
232        Args:
233            condition: Condition string (e.g., "rsi < 30", "close > sma_50")
234            name: Optional name for the condition (for reporting)
235
236        Returns:
237            Self for method chaining
238
239        Supported Syntax:
240            - Simple: "rsi < 30", "volume > 1M"
241            - Field comparison: "close > sma_50", "macd > signal"
242            - Compound (AND): "rsi < 30 and volume > 1M"
243            - Crossover: "sma_20 crosses_above sma_50"
244
245        Examples:
246            >>> scanner.add_condition("rsi < 30", name="oversold")
247            >>> scanner.add_condition("volume > 1M", name="high_volume")
248        """
249        # Split by "and" for multiple conditions
250        parts = [c.strip() for c in condition.lower().split(" and ")]
251
252        for part in parts:
253            if part and part not in self._conditions:
254                self._conditions.append(part)
255                cond_name = name if name and len(parts) == 1 else part
256                self._condition_names[part] = cond_name
257
258        return self

Add a scanning condition.

Conditions are combined with AND logic. For OR logic, use the full condition string: "(rsi < 30 or rsi > 70)".

Args: condition: Condition string (e.g., "rsi < 30", "close > sma_50") name: Optional name for the condition (for reporting)

Returns: Self for method chaining

Supported Syntax: - Simple: "rsi < 30", "volume > 1M" - Field comparison: "close > sma_50", "macd > signal" - Compound (AND): "rsi < 30 and volume > 1M" - Crossover: "sma_20 crosses_above sma_50"

Examples:

scanner.add_condition("rsi < 30", name="oversold") scanner.add_condition("volume > 1M", name="high_volume")

def remove_condition(self, name_or_condition: str) -> TechnicalScanner:
260    def remove_condition(self, name_or_condition: str) -> TechnicalScanner:
261        """Remove a condition by name or condition string.
262
263        Args:
264            name_or_condition: Condition name or string to remove
265
266        Returns:
267            Self for method chaining
268        """
269        # Try to find by name
270        for cond, cname in list(self._condition_names.items()):
271            if cname == name_or_condition or cond == name_or_condition.lower():
272                self._conditions.remove(cond)
273                del self._condition_names[cond]
274                break
275        return self

Remove a condition by name or condition string.

Args: name_or_condition: Condition name or string to remove

Returns: Self for method chaining

def clear_conditions(self) -> TechnicalScanner:
277    def clear_conditions(self) -> TechnicalScanner:
278        """Clear all conditions.
279
280        Returns:
281            Self for method chaining
282        """
283        self._conditions.clear()
284        self._condition_names.clear()
285        return self

Clear all conditions.

Returns: Self for method chaining

def set_interval(self, interval: str) -> TechnicalScanner:
287    def set_interval(self, interval: str) -> TechnicalScanner:
288        """Set the data interval/timeframe for indicators.
289
290        Args:
291            interval: Timeframe for indicators:
292                - "1m", "5m", "15m", "30m" (intraday minutes)
293                - "1h", "2h", "4h" (intraday hours)
294                - "1d" (daily, default)
295                - "1W", "1wk" (weekly)
296                - "1M", "1mo" (monthly)
297
298        Returns:
299            Self for method chaining
300        """
301        self._interval = interval
302        return self

Set the data interval/timeframe for indicators.

Args: interval: Timeframe for indicators: - "1m", "5m", "15m", "30m" (intraday minutes) - "1h", "2h", "4h" (intraday hours) - "1d" (daily, default) - "1W", "1wk" (weekly) - "1M", "1mo" (monthly)

Returns: Self for method chaining

def add_column(self, column: str) -> TechnicalScanner:
304    def add_column(self, column: str) -> TechnicalScanner:
305        """Add extra column to retrieve in results.
306
307        Args:
308            column: Column name (e.g., "ema_200", "adx")
309
310        Returns:
311            Self for method chaining
312        """
313        if column not in self._extra_columns:
314            self._extra_columns.append(column)
315        return self

Add extra column to retrieve in results.

Args: column: Column name (e.g., "ema_200", "adx")

Returns: Self for method chaining

def run(self, limit: int = 100) -> pandas.core.frame.DataFrame:
317    def run(self, limit: int = 100) -> pd.DataFrame:
318        """Execute the scan and return results.
319
320        Args:
321            limit: Maximum number of results
322
323        Returns:
324            DataFrame with matching symbols and their data.
325            Columns include: symbol, close, volume, change, market_cap,
326            plus any indicator columns used in conditions.
327
328        Raises:
329            ValueError: If no symbols or conditions are set
330        """
331        if not self._symbols:
332            return pd.DataFrame()
333
334        if not self._conditions:
335            return pd.DataFrame()
336
337        # Execute scan via provider
338        df = self._provider.scan(
339            symbols=self._symbols,
340            conditions=self._conditions,
341            columns=self._extra_columns,
342            interval=self._interval,
343            limit=limit,
344        )
345
346        # Add conditions_met column for compatibility
347        if not df.empty:
348            df["conditions_met"] = [list(self._condition_names.values())] * len(df)
349
350        return df

Execute the scan and return results.

Args: limit: Maximum number of results

Returns: DataFrame with matching symbols and their data. Columns include: symbol, close, volume, change, market_cap, plus any indicator columns used in conditions.

Raises: ValueError: If no symbols or conditions are set

symbols: list[str]
352    @property
353    def symbols(self) -> list[str]:
354        """Get current symbol universe."""
355        return self._symbols.copy()

Get current symbol universe.

conditions: list[str]
357    @property
358    def conditions(self) -> list[str]:
359        """Get current conditions."""
360        return self._conditions.copy()

Get current conditions.

def set_data_period(self, period: str = '3mo') -> TechnicalScanner:
363    def set_data_period(self, period: str = "3mo") -> TechnicalScanner:
364        """Deprecated: Period is not used with TradingView API."""
365        import warnings
366
367        warnings.warn(
368            "set_data_period() is deprecated. TradingView API uses real-time data.",
369            DeprecationWarning,
370            stacklevel=2,
371        )
372        return self

Deprecated: Period is not used with TradingView API.

results: list[ScanResult]
374    @property
375    def results(self) -> list[ScanResult]:
376        """Deprecated: Use run() which returns DataFrame directly."""
377        return []

Deprecated: Use run() which returns DataFrame directly.

def to_dataframe(self) -> pandas.core.frame.DataFrame:
379    def to_dataframe(self) -> pd.DataFrame:
380        """Deprecated: Use run() which returns DataFrame directly."""
381        return self.run()

Deprecated: Use run() which returns DataFrame directly.

def on_match(self, callback) -> None:
383    def on_match(self, callback) -> None:
384        """Deprecated: Callbacks not supported with batch API."""
385        import warnings
386
387        warnings.warn(
388            "on_match() is deprecated. Use run() and iterate results.",
389            DeprecationWarning,
390            stacklevel=2,
391        )

Deprecated: Callbacks not supported with batch API.

def on_scan_complete(self, callback) -> None:
393    def on_scan_complete(self, callback) -> None:
394        """Deprecated: Callbacks not supported with batch API."""
395        import warnings
396
397        warnings.warn(
398            "on_scan_complete() is deprecated. Use run() directly.",
399            DeprecationWarning,
400            stacklevel=2,
401        )

Deprecated: Callbacks not supported with batch API.

@dataclass
class ScanResult:
49@dataclass
50class ScanResult:
51    """Result of scanning a single symbol.
52
53    Attributes:
54        symbol: The stock symbol
55        data: Dictionary with current values and indicators
56        conditions_met: List of condition names that were satisfied
57        timestamp: When the scan was performed
58    """
59
60    symbol: str
61    data: dict[str, Any] = field(default_factory=dict)
62    conditions_met: list[str] = field(default_factory=list)
63    timestamp: datetime = field(default_factory=datetime.now)

Result of scanning a single symbol.

Attributes: symbol: The stock symbol data: Dictionary with current values and indicators conditions_met: List of condition names that were satisfied timestamp: When the scan was performed

ScanResult( symbol: str, data: dict[str, typing.Any] = <factory>, conditions_met: list[str] = <factory>, timestamp: datetime.datetime = <factory>)
symbol: str
data: dict[str, typing.Any]
conditions_met: list[str]
timestamp: datetime.datetime
def scan( universe: str | list[str], condition: str, interval: str = '1d', limit: int = 100) -> pandas.core.frame.DataFrame:
 66def scan(
 67    universe: str | list[str],
 68    condition: str,
 69    interval: str = "1d",
 70    limit: int = 100,
 71) -> pd.DataFrame:
 72    """Convenience function for quick technical scanning using TradingView API.
 73
 74    This function provides a simple interface to scan stocks based on technical
 75    conditions. All filtering is done server-side by TradingView for fast results.
 76
 77    Args:
 78        universe: Index symbol (e.g., "XU030", "XU100", "XBANK") or list of stock symbols
 79        condition: Condition string supporting:
 80            - Simple comparisons: "rsi < 30", "volume > 1M"
 81            - Field comparisons: "close > sma_50", "macd > signal"
 82            - Compound conditions: "rsi < 30 and close > sma_50"
 83            - Crossover: "sma_20 crosses_above sma_50", "macd crosses signal"
 84            - Percentage: "close above_pct sma_50 1.05", "close below_pct sma_50 0.95"
 85        interval: Timeframe for indicators ("1m", "5m", "15m", "30m", "1h", "4h", "1d", "1W", "1M")
 86        limit: Maximum number of results (default: 100)
 87
 88    Returns:
 89        DataFrame with matching symbols and their indicator values
 90
 91    Examples:
 92        >>> import borsapy as bp
 93
 94        # RSI oversold
 95        >>> bp.scan("XU030", "rsi < 30")
 96
 97        # Price above SMA50
 98        >>> bp.scan("XU100", "close > sma_50")
 99
100        # Compound condition
101        >>> bp.scan("XU030", "rsi < 30 and volume > 1M")
102
103        # MACD bullish
104        >>> bp.scan("XU100", "macd > signal")
105
106        # Golden cross
107        >>> bp.scan("XU030", "sma_20 crosses_above sma_50")
108
109        # MACD crosses signal line
110        >>> bp.scan("XU030", "macd crosses signal")
111
112        # Close 5% above SMA50
113        >>> bp.scan("XU030", "close above_pct sma_50 1.05")
114
115        # Hourly timeframe
116        >>> bp.scan("XU030", "rsi < 30", interval="1h")
117
118    Supported Fields:
119        Price: price, close, open, high, low, volume, change_percent, market_cap
120        RSI: rsi, rsi_7, rsi_14
121        SMA: sma_5, sma_10, sma_20, sma_30, sma_50, sma_100, sma_200
122        EMA: ema_5, ema_10, ema_12, ema_20, ema_26, ema_50, ema_100, ema_200
123        MACD: macd, signal, histogram
124        Stochastic: stoch_k, stoch_d
125        ADX: adx
126        Bollinger: bb_upper, bb_middle, bb_lower
127        ATR: atr
128        CCI: cci
129        Williams %R: wr
130    """
131    scanner = TechnicalScanner()
132    scanner.set_universe(universe)
133    scanner.add_condition(condition)
134    scanner.set_interval(interval)
135    return scanner.run(limit=limit)

Convenience function for quick technical scanning using TradingView API.

This function provides a simple interface to scan stocks based on technical conditions. All filtering is done server-side by TradingView for fast results.

Args: universe: Index symbol (e.g., "XU030", "XU100", "XBANK") or list of stock symbols condition: Condition string supporting: - Simple comparisons: "rsi < 30", "volume > 1M" - Field comparisons: "close > sma_50", "macd > signal" - Compound conditions: "rsi < 30 and close > sma_50" - Crossover: "sma_20 crosses_above sma_50", "macd crosses signal" - Percentage: "close above_pct sma_50 1.05", "close below_pct sma_50 0.95" interval: Timeframe for indicators ("1m", "5m", "15m", "30m", "1h", "4h", "1d", "1W", "1M") limit: Maximum number of results (default: 100)

Returns: DataFrame with matching symbols and their indicator values

Examples:

import borsapy as bp

# RSI oversold
>>> bp.scan("XU030", "rsi < 30")

# Price above SMA50
>>> bp.scan("XU100", "close > sma_50")

# Compound condition
>>> bp.scan("XU030", "rsi < 30 and volume > 1M")

# MACD bullish
>>> bp.scan("XU100", "macd > signal")

# Golden cross
>>> bp.scan("XU030", "sma_20 crosses_above sma_50")

# MACD crosses signal line
>>> bp.scan("XU030", "macd crosses signal")

# Close 5% above SMA50
>>> bp.scan("XU030", "close above_pct sma_50 1.05")

# Hourly timeframe
>>> bp.scan("XU030", "rsi < 30", interval="1h")

Supported Fields: Price: price, close, open, high, low, volume, change_percent, market_cap RSI: rsi, rsi_7, rsi_14 SMA: sma_5, sma_10, sma_20, sma_30, sma_50, sma_100, sma_200 EMA: ema_5, ema_10, ema_12, ema_20, ema_26, ema_50, ema_100, ema_200 MACD: macd, signal, histogram Stochastic: stoch_k, stoch_d ADX: adx Bollinger: bb_upper, bb_middle, bb_lower ATR: atr CCI: cci Williams %R: wr

class TechnicalAnalyzer:
 855class TechnicalAnalyzer:
 856    """Technical analysis wrapper for OHLCV DataFrames.
 857
 858    Provides easy access to technical indicators as methods and properties.
 859
 860    Example:
 861        >>> df = stock.history(period="1y")
 862        >>> ta = TechnicalAnalyzer(df)
 863        >>> ta.rsi()  # Returns full RSI series
 864        >>> ta.latest  # Returns dict with latest values of all indicators
 865    """
 866
 867    def __init__(self, df: pd.DataFrame) -> None:
 868        """Initialize with OHLCV DataFrame.
 869
 870        Args:
 871            df: DataFrame with price data (must have at least 'Close' column)
 872        """
 873        self._df = df.copy()
 874        self._has_volume = "Volume" in df.columns
 875        self._has_hlc = all(col in df.columns for col in ["High", "Low", "Close"])
 876
 877    def sma(self, period: int = 20) -> pd.Series:
 878        """Calculate Simple Moving Average."""
 879        return calculate_sma(self._df, period)
 880
 881    def ema(self, period: int = 20) -> pd.Series:
 882        """Calculate Exponential Moving Average."""
 883        return calculate_ema(self._df, period)
 884
 885    def tilson_t3(self, period: int = 5, vfactor: float = 0.7) -> pd.Series:
 886        """Calculate Tilson T3 Moving Average."""
 887        return calculate_tilson_t3(self._df, period, vfactor)
 888
 889    def rsi(self, period: int = 14) -> pd.Series:
 890        """Calculate Relative Strength Index."""
 891        return calculate_rsi(self._df, period)
 892
 893    def macd(
 894        self, fast: int = 12, slow: int = 26, signal: int = 9
 895    ) -> pd.DataFrame:
 896        """Calculate MACD (line, signal, histogram)."""
 897        return calculate_macd(self._df, fast, slow, signal)
 898
 899    def bollinger_bands(
 900        self, period: int = 20, std_dev: float = 2.0
 901    ) -> pd.DataFrame:
 902        """Calculate Bollinger Bands (upper, middle, lower)."""
 903        return calculate_bollinger_bands(self._df, period, std_dev)
 904
 905    def atr(self, period: int = 14) -> pd.Series:
 906        """Calculate Average True Range."""
 907        return calculate_atr(self._df, period)
 908
 909    def stochastic(self, k_period: int = 14, d_period: int = 3) -> pd.DataFrame:
 910        """Calculate Stochastic Oscillator (%K, %D)."""
 911        return calculate_stochastic(self._df, k_period, d_period)
 912
 913    def obv(self) -> pd.Series:
 914        """Calculate On-Balance Volume."""
 915        return calculate_obv(self._df)
 916
 917    def vwap(self) -> pd.Series:
 918        """Calculate Volume Weighted Average Price."""
 919        return calculate_vwap(self._df)
 920
 921    def adx(self, period: int = 14) -> pd.Series:
 922        """Calculate Average Directional Index."""
 923        return calculate_adx(self._df, period)
 924
 925    def supertrend(self, atr_period: int = 10, multiplier: float = 3.0) -> pd.DataFrame:
 926        """Calculate Supertrend indicator.
 927
 928        Args:
 929            atr_period: Period for ATR calculation (default 10)
 930            multiplier: ATR multiplier for bands (default 3.0)
 931
 932        Returns:
 933            DataFrame with Supertrend, Supertrend_Direction, Supertrend_Upper, Supertrend_Lower
 934        """
 935        return calculate_supertrend(self._df, atr_period, multiplier)
 936
 937    def hhv(self, period: int = 14, column: str = "High") -> pd.Series:
 938        """Calculate Highest High Value (HHV)."""
 939        return calculate_hhv(self._df, period, column)
 940
 941    def llv(self, period: int = 14, column: str = "Low") -> pd.Series:
 942        """Calculate Lowest Low Value (LLV)."""
 943        return calculate_llv(self._df, period, column)
 944
 945    def mom(self, period: int = 10) -> pd.Series:
 946        """Calculate Momentum (MOM)."""
 947        return calculate_mom(self._df, period)
 948
 949    def roc(self, period: int = 10) -> pd.Series:
 950        """Calculate Rate of Change (ROC)."""
 951        return calculate_roc(self._df, period)
 952
 953    def wma(self, period: int = 20) -> pd.Series:
 954        """Calculate Weighted Moving Average (WMA)."""
 955        return calculate_wma(self._df, period)
 956
 957    def dema(self, period: int = 20) -> pd.Series:
 958        """Calculate Double Exponential Moving Average (DEMA)."""
 959        return calculate_dema(self._df, period)
 960
 961    def tema(self, period: int = 20) -> pd.Series:
 962        """Calculate Triple Exponential Moving Average (TEMA)."""
 963        return calculate_tema(self._df, period)
 964
 965    def heikin_ashi(self) -> pd.DataFrame:
 966        """Calculate Heikin Ashi candlestick values.
 967
 968        Returns:
 969            DataFrame with HA_Open, HA_High, HA_Low, HA_Close, Volume columns
 970        """
 971        from borsapy.charts import calculate_heikin_ashi
 972
 973        return calculate_heikin_ashi(self._df)
 974
 975    def all(self, **kwargs: Any) -> pd.DataFrame:
 976        """Get DataFrame with all applicable indicators added."""
 977        return add_indicators(self._df, **kwargs)
 978
 979    @property
 980    def latest(self) -> dict[str, float]:
 981        """Get latest values of all applicable indicators.
 982
 983        Returns:
 984            Dictionary with indicator names and their latest values
 985        """
 986        result: dict[str, float] = {}
 987
 988        # Always available (need Close or Price)
 989        has_price = "Close" in self._df.columns or "Price" in self._df.columns
 990        if has_price and len(self._df) > 0:
 991            result["sma_20"] = float(self.sma(20).iloc[-1])
 992            result["sma_50"] = float(self.sma(50).iloc[-1])
 993            result["ema_12"] = float(self.ema(12).iloc[-1])
 994            result["ema_26"] = float(self.ema(26).iloc[-1])
 995            result["t3_5"] = float(self.tilson_t3(5).iloc[-1])
 996            result["rsi_14"] = float(self.rsi(14).iloc[-1])
 997
 998            macd_df = self.macd()
 999            result["macd"] = float(macd_df["MACD"].iloc[-1])
1000            result["macd_signal"] = float(macd_df["Signal"].iloc[-1])
1001            result["macd_histogram"] = float(macd_df["Histogram"].iloc[-1])
1002
1003            bb_df = self.bollinger_bands()
1004            result["bb_upper"] = float(bb_df["BB_Upper"].iloc[-1])
1005            result["bb_middle"] = float(bb_df["BB_Middle"].iloc[-1])
1006            result["bb_lower"] = float(bb_df["BB_Lower"].iloc[-1])
1007
1008        # MetaStock indicators (need Close or Price)
1009        if has_price and len(self._df) > 0:
1010            result["mom_10"] = float(self.mom(10).iloc[-1])
1011            result["roc_10"] = float(self.roc(10).iloc[-1])
1012            result["wma_20"] = float(self.wma(20).iloc[-1])
1013            result["dema_20"] = float(self.dema(20).iloc[-1])
1014            result["tema_20"] = float(self.tema(20).iloc[-1])
1015
1016        # Need High, Low, Close
1017        if self._has_hlc and len(self._df) > 0:
1018            result["atr_14"] = float(self.atr(14).iloc[-1])
1019            result["adx_14"] = float(self.adx(14).iloc[-1])
1020            result["hhv_14"] = float(self.hhv(14).iloc[-1])
1021            result["llv_14"] = float(self.llv(14).iloc[-1])
1022
1023            stoch_df = self.stochastic()
1024            result["stoch_k"] = float(stoch_df["Stoch_K"].iloc[-1])
1025            result["stoch_d"] = float(stoch_df["Stoch_D"].iloc[-1])
1026
1027            st_df = self.supertrend()
1028            result["supertrend"] = float(st_df["Supertrend"].iloc[-1])
1029            result["supertrend_direction"] = float(st_df["Supertrend_Direction"].iloc[-1])
1030
1031        # Need Volume
1032        if self._has_volume and len(self._df) > 0:
1033            result["obv"] = float(self.obv().iloc[-1])
1034
1035        # Need HLC + Volume
1036        if self._has_hlc and self._has_volume and len(self._df) > 0:
1037            result["vwap"] = float(self.vwap().iloc[-1])
1038
1039        # Round all values
1040        return {k: round(v, 4) if not np.isnan(v) else np.nan for k, v in result.items()}

Technical analysis wrapper for OHLCV DataFrames.

Provides easy access to technical indicators as methods and properties.

Example:

df = stock.history(period="1y") ta = TechnicalAnalyzer(df) ta.rsi() # Returns full RSI series ta.latest # Returns dict with latest values of all indicators

TechnicalAnalyzer(df: pandas.core.frame.DataFrame)
867    def __init__(self, df: pd.DataFrame) -> None:
868        """Initialize with OHLCV DataFrame.
869
870        Args:
871            df: DataFrame with price data (must have at least 'Close' column)
872        """
873        self._df = df.copy()
874        self._has_volume = "Volume" in df.columns
875        self._has_hlc = all(col in df.columns for col in ["High", "Low", "Close"])

Initialize with OHLCV DataFrame.

Args: df: DataFrame with price data (must have at least 'Close' column)

def sma(self, period: int = 20) -> pandas.core.series.Series:
877    def sma(self, period: int = 20) -> pd.Series:
878        """Calculate Simple Moving Average."""
879        return calculate_sma(self._df, period)

Calculate Simple Moving Average.

def ema(self, period: int = 20) -> pandas.core.series.Series:
881    def ema(self, period: int = 20) -> pd.Series:
882        """Calculate Exponential Moving Average."""
883        return calculate_ema(self._df, period)

Calculate Exponential Moving Average.

def tilson_t3(self, period: int = 5, vfactor: float = 0.7) -> pandas.core.series.Series:
885    def tilson_t3(self, period: int = 5, vfactor: float = 0.7) -> pd.Series:
886        """Calculate Tilson T3 Moving Average."""
887        return calculate_tilson_t3(self._df, period, vfactor)

Calculate Tilson T3 Moving Average.

def rsi(self, period: int = 14) -> pandas.core.series.Series:
889    def rsi(self, period: int = 14) -> pd.Series:
890        """Calculate Relative Strength Index."""
891        return calculate_rsi(self._df, period)

Calculate Relative Strength Index.

def macd( self, fast: int = 12, slow: int = 26, signal: int = 9) -> pandas.core.frame.DataFrame:
893    def macd(
894        self, fast: int = 12, slow: int = 26, signal: int = 9
895    ) -> pd.DataFrame:
896        """Calculate MACD (line, signal, histogram)."""
897        return calculate_macd(self._df, fast, slow, signal)

Calculate MACD (line, signal, histogram).

def bollinger_bands( self, period: int = 20, std_dev: float = 2.0) -> pandas.core.frame.DataFrame:
899    def bollinger_bands(
900        self, period: int = 20, std_dev: float = 2.0
901    ) -> pd.DataFrame:
902        """Calculate Bollinger Bands (upper, middle, lower)."""
903        return calculate_bollinger_bands(self._df, period, std_dev)

Calculate Bollinger Bands (upper, middle, lower).

def atr(self, period: int = 14) -> pandas.core.series.Series:
905    def atr(self, period: int = 14) -> pd.Series:
906        """Calculate Average True Range."""
907        return calculate_atr(self._df, period)

Calculate Average True Range.

def stochastic( self, k_period: int = 14, d_period: int = 3) -> pandas.core.frame.DataFrame:
909    def stochastic(self, k_period: int = 14, d_period: int = 3) -> pd.DataFrame:
910        """Calculate Stochastic Oscillator (%K, %D)."""
911        return calculate_stochastic(self._df, k_period, d_period)

Calculate Stochastic Oscillator (%K, %D).

def obv(self) -> pandas.core.series.Series:
913    def obv(self) -> pd.Series:
914        """Calculate On-Balance Volume."""
915        return calculate_obv(self._df)

Calculate On-Balance Volume.

def vwap(self) -> pandas.core.series.Series:
917    def vwap(self) -> pd.Series:
918        """Calculate Volume Weighted Average Price."""
919        return calculate_vwap(self._df)

Calculate Volume Weighted Average Price.

def adx(self, period: int = 14) -> pandas.core.series.Series:
921    def adx(self, period: int = 14) -> pd.Series:
922        """Calculate Average Directional Index."""
923        return calculate_adx(self._df, period)

Calculate Average Directional Index.

def supertrend( self, atr_period: int = 10, multiplier: float = 3.0) -> pandas.core.frame.DataFrame:
925    def supertrend(self, atr_period: int = 10, multiplier: float = 3.0) -> pd.DataFrame:
926        """Calculate Supertrend indicator.
927
928        Args:
929            atr_period: Period for ATR calculation (default 10)
930            multiplier: ATR multiplier for bands (default 3.0)
931
932        Returns:
933            DataFrame with Supertrend, Supertrend_Direction, Supertrend_Upper, Supertrend_Lower
934        """
935        return calculate_supertrend(self._df, atr_period, multiplier)

Calculate Supertrend indicator.

Args: atr_period: Period for ATR calculation (default 10) multiplier: ATR multiplier for bands (default 3.0)

Returns: DataFrame with Supertrend, Supertrend_Direction, Supertrend_Upper, Supertrend_Lower

def hhv( self, period: int = 14, column: str = 'High') -> pandas.core.series.Series:
937    def hhv(self, period: int = 14, column: str = "High") -> pd.Series:
938        """Calculate Highest High Value (HHV)."""
939        return calculate_hhv(self._df, period, column)

Calculate Highest High Value (HHV).

def llv(self, period: int = 14, column: str = 'Low') -> pandas.core.series.Series:
941    def llv(self, period: int = 14, column: str = "Low") -> pd.Series:
942        """Calculate Lowest Low Value (LLV)."""
943        return calculate_llv(self._df, period, column)

Calculate Lowest Low Value (LLV).

def mom(self, period: int = 10) -> pandas.core.series.Series:
945    def mom(self, period: int = 10) -> pd.Series:
946        """Calculate Momentum (MOM)."""
947        return calculate_mom(self._df, period)

Calculate Momentum (MOM).

def roc(self, period: int = 10) -> pandas.core.series.Series:
949    def roc(self, period: int = 10) -> pd.Series:
950        """Calculate Rate of Change (ROC)."""
951        return calculate_roc(self._df, period)

Calculate Rate of Change (ROC).

def wma(self, period: int = 20) -> pandas.core.series.Series:
953    def wma(self, period: int = 20) -> pd.Series:
954        """Calculate Weighted Moving Average (WMA)."""
955        return calculate_wma(self._df, period)

Calculate Weighted Moving Average (WMA).

def dema(self, period: int = 20) -> pandas.core.series.Series:
957    def dema(self, period: int = 20) -> pd.Series:
958        """Calculate Double Exponential Moving Average (DEMA)."""
959        return calculate_dema(self._df, period)

Calculate Double Exponential Moving Average (DEMA).

def tema(self, period: int = 20) -> pandas.core.series.Series:
961    def tema(self, period: int = 20) -> pd.Series:
962        """Calculate Triple Exponential Moving Average (TEMA)."""
963        return calculate_tema(self._df, period)

Calculate Triple Exponential Moving Average (TEMA).

def heikin_ashi(self) -> pandas.core.frame.DataFrame:
965    def heikin_ashi(self) -> pd.DataFrame:
966        """Calculate Heikin Ashi candlestick values.
967
968        Returns:
969            DataFrame with HA_Open, HA_High, HA_Low, HA_Close, Volume columns
970        """
971        from borsapy.charts import calculate_heikin_ashi
972
973        return calculate_heikin_ashi(self._df)

Calculate Heikin Ashi candlestick values.

Returns: DataFrame with HA_Open, HA_High, HA_Low, HA_Close, Volume columns

def all(self, **kwargs: Any) -> pandas.core.frame.DataFrame:
975    def all(self, **kwargs: Any) -> pd.DataFrame:
976        """Get DataFrame with all applicable indicators added."""
977        return add_indicators(self._df, **kwargs)

Get DataFrame with all applicable indicators added.

latest: dict[str, float]
 979    @property
 980    def latest(self) -> dict[str, float]:
 981        """Get latest values of all applicable indicators.
 982
 983        Returns:
 984            Dictionary with indicator names and their latest values
 985        """
 986        result: dict[str, float] = {}
 987
 988        # Always available (need Close or Price)
 989        has_price = "Close" in self._df.columns or "Price" in self._df.columns
 990        if has_price and len(self._df) > 0:
 991            result["sma_20"] = float(self.sma(20).iloc[-1])
 992            result["sma_50"] = float(self.sma(50).iloc[-1])
 993            result["ema_12"] = float(self.ema(12).iloc[-1])
 994            result["ema_26"] = float(self.ema(26).iloc[-1])
 995            result["t3_5"] = float(self.tilson_t3(5).iloc[-1])
 996            result["rsi_14"] = float(self.rsi(14).iloc[-1])
 997
 998            macd_df = self.macd()
 999            result["macd"] = float(macd_df["MACD"].iloc[-1])
1000            result["macd_signal"] = float(macd_df["Signal"].iloc[-1])
1001            result["macd_histogram"] = float(macd_df["Histogram"].iloc[-1])
1002
1003            bb_df = self.bollinger_bands()
1004            result["bb_upper"] = float(bb_df["BB_Upper"].iloc[-1])
1005            result["bb_middle"] = float(bb_df["BB_Middle"].iloc[-1])
1006            result["bb_lower"] = float(bb_df["BB_Lower"].iloc[-1])
1007
1008        # MetaStock indicators (need Close or Price)
1009        if has_price and len(self._df) > 0:
1010            result["mom_10"] = float(self.mom(10).iloc[-1])
1011            result["roc_10"] = float(self.roc(10).iloc[-1])
1012            result["wma_20"] = float(self.wma(20).iloc[-1])
1013            result["dema_20"] = float(self.dema(20).iloc[-1])
1014            result["tema_20"] = float(self.tema(20).iloc[-1])
1015
1016        # Need High, Low, Close
1017        if self._has_hlc and len(self._df) > 0:
1018            result["atr_14"] = float(self.atr(14).iloc[-1])
1019            result["adx_14"] = float(self.adx(14).iloc[-1])
1020            result["hhv_14"] = float(self.hhv(14).iloc[-1])
1021            result["llv_14"] = float(self.llv(14).iloc[-1])
1022
1023            stoch_df = self.stochastic()
1024            result["stoch_k"] = float(stoch_df["Stoch_K"].iloc[-1])
1025            result["stoch_d"] = float(stoch_df["Stoch_D"].iloc[-1])
1026
1027            st_df = self.supertrend()
1028            result["supertrend"] = float(st_df["Supertrend"].iloc[-1])
1029            result["supertrend_direction"] = float(st_df["Supertrend_Direction"].iloc[-1])
1030
1031        # Need Volume
1032        if self._has_volume and len(self._df) > 0:
1033            result["obv"] = float(self.obv().iloc[-1])
1034
1035        # Need HLC + Volume
1036        if self._has_hlc and self._has_volume and len(self._df) > 0:
1037            result["vwap"] = float(self.vwap().iloc[-1])
1038
1039        # Round all values
1040        return {k: round(v, 4) if not np.isnan(v) else np.nan for k, v in result.items()}

Get latest values of all applicable indicators.

Returns: Dictionary with indicator names and their latest values

def add_indicators( df: pandas.core.frame.DataFrame, indicators: list[str] | None = None, **kwargs: Any) -> pandas.core.frame.DataFrame:
729def add_indicators(
730    df: pd.DataFrame,
731    indicators: list[str] | None = None,
732    **kwargs: Any,
733) -> pd.DataFrame:
734    """Add technical indicator columns to a DataFrame.
735
736    Args:
737        df: DataFrame with OHLCV data (Open, High, Low, Close, Volume)
738        indicators: List of indicators to add. If None, adds all applicable.
739            Options: 'sma', 'ema', 'rsi', 'macd', 'bollinger', 'atr',
740                     'stochastic', 'obv', 'vwap', 'adx', 'supertrend',
741                     'hhv', 'llv', 'mom', 'roc', 'wma', 'dema', 'tema'
742        **kwargs: Additional arguments for specific indicators:
743            - sma_period: SMA period (default 20)
744            - ema_period: EMA period (default 12)
745            - rsi_period: RSI period (default 14)
746            - bb_period: Bollinger Bands period (default 20)
747            - atr_period: ATR period (default 14)
748            - adx_period: ADX period (default 14)
749            - supertrend_period: Supertrend ATR period (default 10)
750            - supertrend_multiplier: Supertrend ATR multiplier (default 3.0)
751            - hhv_period: HHV period (default 14)
752            - llv_period: LLV period (default 14)
753            - mom_period: MOM period (default 10)
754            - roc_period: ROC period (default 10)
755            - wma_period: WMA period (default 20)
756            - dema_period: DEMA period (default 20)
757            - tema_period: TEMA period (default 20)
758
759    Returns:
760        DataFrame with indicator columns added
761    """
762    result = df.copy()
763
764    # Default indicators based on available columns
765    has_volume = "Volume" in df.columns
766    has_hlc = all(col in df.columns for col in ["High", "Low", "Close"])
767
768    if indicators is None:
769        indicators = ["sma", "ema", "rsi", "macd", "bollinger"]
770        if has_hlc:
771            indicators.extend(["atr", "stochastic", "adx", "supertrend"])
772        if has_volume:
773            indicators.append("obv")
774        if has_volume and has_hlc:
775            indicators.append("vwap")
776
777    # Get periods from kwargs
778    sma_period = kwargs.get("sma_period", 20)
779    ema_period = kwargs.get("ema_period", 12)
780    rsi_period = kwargs.get("rsi_period", 14)
781    bb_period = kwargs.get("bb_period", 20)
782    atr_period = kwargs.get("atr_period", 14)
783    adx_period = kwargs.get("adx_period", 14)
784    supertrend_period = kwargs.get("supertrend_period", 10)
785    supertrend_multiplier = kwargs.get("supertrend_multiplier", 3.0)
786    hhv_period = kwargs.get("hhv_period", 14)
787    llv_period = kwargs.get("llv_period", 14)
788    mom_period = kwargs.get("mom_period", 10)
789    roc_period = kwargs.get("roc_period", 10)
790    wma_period = kwargs.get("wma_period", 20)
791    dema_period = kwargs.get("dema_period", 20)
792    tema_period = kwargs.get("tema_period", 20)
793
794    # Add indicators
795    for indicator in indicators:
796        indicator = indicator.lower()
797
798        if indicator == "sma":
799            result[f"SMA_{sma_period}"] = calculate_sma(df, sma_period)
800        elif indicator == "ema":
801            result[f"EMA_{ema_period}"] = calculate_ema(df, ema_period)
802        elif indicator == "rsi":
803            result[f"RSI_{rsi_period}"] = calculate_rsi(df, rsi_period)
804        elif indicator == "macd":
805            macd_df = calculate_macd(df)
806            result["MACD"] = macd_df["MACD"]
807            result["MACD_Signal"] = macd_df["Signal"]
808            result["MACD_Hist"] = macd_df["Histogram"]
809        elif indicator == "bollinger":
810            bb_df = calculate_bollinger_bands(df, bb_period)
811            result["BB_Upper"] = bb_df["BB_Upper"]
812            result["BB_Middle"] = bb_df["BB_Middle"]
813            result["BB_Lower"] = bb_df["BB_Lower"]
814        elif indicator == "atr" and has_hlc:
815            result[f"ATR_{atr_period}"] = calculate_atr(df, atr_period)
816        elif indicator == "stochastic" and has_hlc:
817            stoch_df = calculate_stochastic(df)
818            result["Stoch_K"] = stoch_df["Stoch_K"]
819            result["Stoch_D"] = stoch_df["Stoch_D"]
820        elif indicator == "obv" and has_volume:
821            result["OBV"] = calculate_obv(df)
822        elif indicator == "vwap" and has_volume and has_hlc:
823            result["VWAP"] = calculate_vwap(df)
824        elif indicator == "adx" and has_hlc:
825            result[f"ADX_{adx_period}"] = calculate_adx(df, adx_period)
826        elif indicator == "supertrend" and has_hlc:
827            st_df = calculate_supertrend(df, supertrend_period, supertrend_multiplier)
828            result["Supertrend"] = st_df["Supertrend"]
829            result["Supertrend_Direction"] = st_df["Supertrend_Direction"]
830        elif indicator == "hhv":
831            col = "High" if "High" in df.columns else "Close"
832            result[f"HHV_{hhv_period}"] = calculate_hhv(df, hhv_period, col)
833        elif indicator == "llv":
834            col = "Low" if "Low" in df.columns else "Close"
835            result[f"LLV_{llv_period}"] = calculate_llv(df, llv_period, col)
836        elif indicator == "mom":
837            result[f"MOM_{mom_period}"] = calculate_mom(df, mom_period)
838        elif indicator == "roc":
839            result[f"ROC_{roc_period}"] = calculate_roc(df, roc_period)
840        elif indicator == "wma":
841            result[f"WMA_{wma_period}"] = calculate_wma(df, wma_period)
842        elif indicator == "dema":
843            result[f"DEMA_{dema_period}"] = calculate_dema(df, dema_period)
844        elif indicator == "tema":
845            result[f"TEMA_{tema_period}"] = calculate_tema(df, tema_period)
846
847    return result

Add technical indicator columns to a DataFrame.

Args: df: DataFrame with OHLCV data (Open, High, Low, Close, Volume) indicators: List of indicators to add. If None, adds all applicable. Options: 'sma', 'ema', 'rsi', 'macd', 'bollinger', 'atr', 'stochastic', 'obv', 'vwap', 'adx', 'supertrend', 'hhv', 'llv', 'mom', 'roc', 'wma', 'dema', 'tema' **kwargs: Additional arguments for specific indicators: - sma_period: SMA period (default 20) - ema_period: EMA period (default 12) - rsi_period: RSI period (default 14) - bb_period: Bollinger Bands period (default 20) - atr_period: ATR period (default 14) - adx_period: ADX period (default 14) - supertrend_period: Supertrend ATR period (default 10) - supertrend_multiplier: Supertrend ATR multiplier (default 3.0) - hhv_period: HHV period (default 14) - llv_period: LLV period (default 14) - mom_period: MOM period (default 10) - roc_period: ROC period (default 10) - wma_period: WMA period (default 20) - dema_period: DEMA period (default 20) - tema_period: TEMA period (default 20)

Returns: DataFrame with indicator columns added

def calculate_sma( df: pandas.core.frame.DataFrame, period: int = 20, column: str = 'Close') -> pandas.core.series.Series:
62def calculate_sma(
63    df: pd.DataFrame, period: int = 20, column: str = "Close"
64) -> pd.Series:
65    """Calculate Simple Moving Average (SMA).
66
67    Args:
68        df: DataFrame with price data
69        period: Number of periods for moving average
70        column: Column name to use for calculation
71
72    Returns:
73        Series with SMA values
74    """
75    col = _get_price_column(df, column)
76    if col not in df.columns:
77        return pd.Series(np.nan, index=df.index, name=f"SMA_{period}")
78    return df[col].rolling(window=period, min_periods=1).mean()

Calculate Simple Moving Average (SMA).

Args: df: DataFrame with price data period: Number of periods for moving average column: Column name to use for calculation

Returns: Series with SMA values

def calculate_ema( df: pandas.core.frame.DataFrame, period: int = 20, column: str = 'Close') -> pandas.core.series.Series:
81def calculate_ema(
82    df: pd.DataFrame, period: int = 20, column: str = "Close"
83) -> pd.Series:
84    """Calculate Exponential Moving Average (EMA).
85
86    Args:
87        df: DataFrame with price data
88        period: Number of periods for moving average
89        column: Column name to use for calculation
90
91    Returns:
92        Series with EMA values
93    """
94    col = _get_price_column(df, column)
95    if col not in df.columns:
96        return pd.Series(np.nan, index=df.index, name=f"EMA_{period}")
97    return df[col].ewm(span=period, adjust=False).mean()

Calculate Exponential Moving Average (EMA).

Args: df: DataFrame with price data period: Number of periods for moving average column: Column name to use for calculation

Returns: Series with EMA values

def calculate_rsi( df: pandas.core.frame.DataFrame, period: int = 14, column: str = 'Close') -> pandas.core.series.Series:
157def calculate_rsi(
158    df: pd.DataFrame, period: int = 14, column: str = "Close"
159) -> pd.Series:
160    """Calculate Relative Strength Index (RSI).
161
162    RSI measures the speed and magnitude of price movements on a scale of 0-100.
163    - RSI > 70: Overbought (potential sell signal)
164    - RSI < 30: Oversold (potential buy signal)
165
166    Args:
167        df: DataFrame with price data
168        period: Number of periods for RSI calculation (default 14)
169        column: Column name to use for calculation
170
171    Returns:
172        Series with RSI values (0-100)
173    """
174    col = _get_price_column(df, column)
175    if col not in df.columns or len(df) < period:
176        return pd.Series(np.nan, index=df.index, name=f"RSI_{period}")
177
178    delta = df[col].diff()
179    gain = delta.where(delta > 0, 0.0)
180    loss = (-delta).where(delta < 0, 0.0)
181
182    # Use Wilder's smoothing (same as TradingView)
183    # Wilder's uses alpha=1/period, NOT span=period
184    avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
185    avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()
186
187    rs = avg_gain / avg_loss
188    rsi = 100.0 - (100.0 / (1.0 + rs))
189
190    # Handle division by zero
191    rsi = rsi.replace([np.inf, -np.inf], np.nan)
192    rsi = rsi.fillna(50.0)  # Neutral RSI when no movement
193
194    return rsi.rename(f"RSI_{period}")

Calculate Relative Strength Index (RSI).

RSI measures the speed and magnitude of price movements on a scale of 0-100.

  • RSI > 70: Overbought (potential sell signal)
  • RSI < 30: Oversold (potential buy signal)

Args: df: DataFrame with price data period: Number of periods for RSI calculation (default 14) column: Column name to use for calculation

Returns: Series with RSI values (0-100)

def calculate_macd( df: pandas.core.frame.DataFrame, fast: int = 12, slow: int = 26, signal: int = 9, column: str = 'Close') -> pandas.core.frame.DataFrame:
197def calculate_macd(
198    df: pd.DataFrame,
199    fast: int = 12,
200    slow: int = 26,
201    signal: int = 9,
202    column: str = "Close",
203) -> pd.DataFrame:
204    """Calculate Moving Average Convergence Divergence (MACD).
205
206    MACD shows the relationship between two moving averages of prices.
207    - MACD Line: Fast EMA - Slow EMA
208    - Signal Line: EMA of MACD Line
209    - Histogram: MACD Line - Signal Line
210
211    Args:
212        df: DataFrame with price data
213        fast: Fast EMA period (default 12)
214        slow: Slow EMA period (default 26)
215        signal: Signal line EMA period (default 9)
216        column: Column name to use for calculation
217
218    Returns:
219        DataFrame with columns: MACD, Signal, Histogram
220    """
221    col = _get_price_column(df, column)
222    if col not in df.columns:
223        return pd.DataFrame(
224            {"MACD": np.nan, "Signal": np.nan, "Histogram": np.nan},
225            index=df.index,
226        )
227
228    ema_fast = df[col].ewm(span=fast, adjust=False).mean()
229    ema_slow = df[col].ewm(span=slow, adjust=False).mean()
230
231    macd_line = ema_fast - ema_slow
232    signal_line = macd_line.ewm(span=signal, adjust=False).mean()
233    histogram = macd_line - signal_line
234
235    return pd.DataFrame(
236        {"MACD": macd_line, "Signal": signal_line, "Histogram": histogram},
237        index=df.index,
238    )

Calculate Moving Average Convergence Divergence (MACD).

MACD shows the relationship between two moving averages of prices.

  • MACD Line: Fast EMA - Slow EMA
  • Signal Line: EMA of MACD Line
  • Histogram: MACD Line - Signal Line

Args: df: DataFrame with price data fast: Fast EMA period (default 12) slow: Slow EMA period (default 26) signal: Signal line EMA period (default 9) column: Column name to use for calculation

Returns: DataFrame with columns: MACD, Signal, Histogram

def calculate_bollinger_bands( df: pandas.core.frame.DataFrame, period: int = 20, std_dev: float = 2.0, column: str = 'Close') -> pandas.core.frame.DataFrame:
241def calculate_bollinger_bands(
242    df: pd.DataFrame, period: int = 20, std_dev: float = 2.0, column: str = "Close"
243) -> pd.DataFrame:
244    """Calculate Bollinger Bands.
245
246    Bollinger Bands consist of a middle band (SMA) and two outer bands
247    at standard deviation levels above and below the middle band.
248
249    Args:
250        df: DataFrame with price data
251        period: Period for SMA and standard deviation
252        std_dev: Number of standard deviations for bands
253        column: Column name to use for calculation
254
255    Returns:
256        DataFrame with columns: Upper, Middle, Lower
257    """
258    col = _get_price_column(df, column)
259    if col not in df.columns:
260        return pd.DataFrame(
261            {"BB_Upper": np.nan, "BB_Middle": np.nan, "BB_Lower": np.nan},
262            index=df.index,
263        )
264
265    middle = df[col].rolling(window=period, min_periods=1).mean()
266    std = df[col].rolling(window=period, min_periods=1).std()
267
268    upper = middle + (std * std_dev)
269    lower = middle - (std * std_dev)
270
271    return pd.DataFrame(
272        {"BB_Upper": upper, "BB_Middle": middle, "BB_Lower": lower},
273        index=df.index,
274    )

Calculate Bollinger Bands.

Bollinger Bands consist of a middle band (SMA) and two outer bands at standard deviation levels above and below the middle band.

Args: df: DataFrame with price data period: Period for SMA and standard deviation std_dev: Number of standard deviations for bands column: Column name to use for calculation

Returns: DataFrame with columns: Upper, Middle, Lower

def calculate_atr( df: pandas.core.frame.DataFrame, period: int = 14) -> pandas.core.series.Series:
277def calculate_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
278    """Calculate Average True Range (ATR).
279
280    ATR measures market volatility by decomposing the entire range of an asset
281    price for that period.
282
283    Args:
284        df: DataFrame with High, Low, Close columns
285        period: Period for ATR calculation
286
287    Returns:
288        Series with ATR values
289    """
290    required = ["High", "Low", "Close"]
291    if not all(col in df.columns for col in required):
292        return pd.Series(np.nan, index=df.index, name=f"ATR_{period}")
293
294    high = df["High"]
295    low = df["Low"]
296    close = df["Close"]
297
298    # True Range components
299    tr1 = high - low
300    tr2 = abs(high - close.shift(1))
301    tr3 = abs(low - close.shift(1))
302
303    # True Range is the maximum of the three
304    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
305
306    # ATR uses Wilder's smoothing (same as TradingView)
307    atr = tr.ewm(alpha=1 / period, adjust=False).mean()
308
309    return atr.rename(f"ATR_{period}")

Calculate Average True Range (ATR).

ATR measures market volatility by decomposing the entire range of an asset price for that period.

Args: df: DataFrame with High, Low, Close columns period: Period for ATR calculation

Returns: Series with ATR values

def calculate_stochastic( df: pandas.core.frame.DataFrame, k_period: int = 14, d_period: int = 3) -> pandas.core.frame.DataFrame:
312def calculate_stochastic(
313    df: pd.DataFrame, k_period: int = 14, d_period: int = 3
314) -> pd.DataFrame:
315    """Calculate Stochastic Oscillator (%K and %D).
316
317    The Stochastic Oscillator compares a closing price to a range of prices
318    over a certain period of time.
319    - %K > 80: Overbought
320    - %K < 20: Oversold
321
322    Args:
323        df: DataFrame with High, Low, Close columns
324        k_period: Period for %K calculation
325        d_period: Period for %D (signal line)
326
327    Returns:
328        DataFrame with columns: Stoch_K, Stoch_D
329    """
330    required = ["High", "Low", "Close"]
331    if not all(col in df.columns for col in required):
332        return pd.DataFrame(
333            {"Stoch_K": np.nan, "Stoch_D": np.nan},
334            index=df.index,
335        )
336
337    # Calculate %K
338    lowest_low = df["Low"].rolling(window=k_period, min_periods=1).min()
339    highest_high = df["High"].rolling(window=k_period, min_periods=1).max()
340
341    stoch_k = 100 * (df["Close"] - lowest_low) / (highest_high - lowest_low)
342    stoch_k = stoch_k.replace([np.inf, -np.inf], np.nan).fillna(50.0)
343
344    # %D is the SMA of %K
345    stoch_d = stoch_k.rolling(window=d_period, min_periods=1).mean()
346
347    return pd.DataFrame(
348        {"Stoch_K": stoch_k, "Stoch_D": stoch_d},
349        index=df.index,
350    )

Calculate Stochastic Oscillator (%K and %D).

The Stochastic Oscillator compares a closing price to a range of prices over a certain period of time.

  • %K > 80: Overbought
  • %K < 20: Oversold

Args: df: DataFrame with High, Low, Close columns k_period: Period for %K calculation d_period: Period for %D (signal line)

Returns: DataFrame with columns: Stoch_K, Stoch_D

def calculate_obv(df: pandas.core.frame.DataFrame) -> pandas.core.series.Series:
353def calculate_obv(df: pd.DataFrame) -> pd.Series:
354    """Calculate On-Balance Volume (OBV).
355
356    OBV uses volume flow to predict changes in stock price.
357    Rising OBV indicates positive volume pressure that can lead to higher prices.
358
359    Args:
360        df: DataFrame with Close and Volume columns
361
362    Returns:
363        Series with OBV values
364    """
365    required = ["Close", "Volume"]
366    if not all(col in df.columns for col in required):
367        return pd.Series(np.nan, index=df.index, name="OBV")
368
369    # Direction: +1 if close > previous close, -1 if close < previous close, 0 if equal
370    direction = np.sign(df["Close"].diff())
371    direction.iloc[0] = 0  # First value has no direction
372
373    # OBV is cumulative sum of signed volume
374    obv = (direction * df["Volume"]).cumsum()
375
376    return obv.rename("OBV")

Calculate On-Balance Volume (OBV).

OBV uses volume flow to predict changes in stock price. Rising OBV indicates positive volume pressure that can lead to higher prices.

Args: df: DataFrame with Close and Volume columns

Returns: Series with OBV values

def calculate_vwap(df: pandas.core.frame.DataFrame) -> pandas.core.series.Series:
379def calculate_vwap(df: pd.DataFrame) -> pd.Series:
380    """Calculate Volume Weighted Average Price (VWAP).
381
382    VWAP gives the average price weighted by volume.
383    It's often used as a trading benchmark.
384
385    Args:
386        df: DataFrame with High, Low, Close, Volume columns
387
388    Returns:
389        Series with VWAP values
390    """
391    required = ["High", "Low", "Close", "Volume"]
392    if not all(col in df.columns for col in required):
393        return pd.Series(np.nan, index=df.index, name="VWAP")
394
395    # Typical Price
396    typical_price = (df["High"] + df["Low"] + df["Close"]) / 3
397
398    # Cumulative TP * Volume / Cumulative Volume
399    cumulative_tp_vol = (typical_price * df["Volume"]).cumsum()
400    cumulative_vol = df["Volume"].cumsum()
401
402    vwap = cumulative_tp_vol / cumulative_vol
403    vwap = vwap.replace([np.inf, -np.inf], np.nan)
404
405    return vwap.rename("VWAP")

Calculate Volume Weighted Average Price (VWAP).

VWAP gives the average price weighted by volume. It's often used as a trading benchmark.

Args: df: DataFrame with High, Low, Close, Volume columns

Returns: Series with VWAP values

def calculate_adx( df: pandas.core.frame.DataFrame, period: int = 14) -> pandas.core.series.Series:
408def calculate_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
409    """Calculate Average Directional Index (ADX).
410
411    ADX measures the strength of a trend regardless of its direction.
412    - ADX > 25: Strong trend
413    - ADX < 20: Weak or no trend
414
415    Args:
416        df: DataFrame with High, Low, Close columns
417        period: Period for ADX calculation
418
419    Returns:
420        Series with ADX values
421    """
422    required = ["High", "Low", "Close"]
423    if not all(col in df.columns for col in required):
424        return pd.Series(np.nan, index=df.index, name=f"ADX_{period}")
425
426    high = df["High"]
427    low = df["Low"]
428    close = df["Close"]
429
430    # Calculate +DM and -DM
431    plus_dm = high.diff()
432    minus_dm = -low.diff()
433
434    plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0)
435    minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0)
436
437    # True Range
438    tr1 = high - low
439    tr2 = abs(high - close.shift(1))
440    tr3 = abs(low - close.shift(1))
441    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
442
443    # Smoothed values using Wilder's smoothing (same as TradingView)
444    atr = tr.ewm(alpha=1 / period, adjust=False).mean()
445    plus_di = 100 * (plus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr)
446    minus_di = 100 * (minus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr)
447
448    # DX and ADX
449    dx = 100 * abs(plus_di - minus_di) / (plus_di + minus_di)
450    dx = dx.replace([np.inf, -np.inf], np.nan).fillna(0)
451
452    adx = dx.ewm(alpha=1 / period, adjust=False).mean()
453
454    return adx.rename(f"ADX_{period}")

Calculate Average Directional Index (ADX).

ADX measures the strength of a trend regardless of its direction.

  • ADX > 25: Strong trend
  • ADX < 20: Weak or no trend

Args: df: DataFrame with High, Low, Close columns period: Period for ADX calculation

Returns: Series with ADX values

def calculate_supertrend( df: pandas.core.frame.DataFrame, atr_period: int = 10, multiplier: float = 3.0) -> pandas.core.frame.DataFrame:
457def calculate_supertrend(
458    df: pd.DataFrame, atr_period: int = 10, multiplier: float = 3.0
459) -> pd.DataFrame:
460    """Calculate Supertrend indicator.
461
462    Supertrend is a trend-following indicator based on ATR.
463    - When price is above Supertrend line: Bullish (uptrend)
464    - When price is below Supertrend line: Bearish (downtrend)
465
466    Args:
467        df: DataFrame with High, Low, Close columns
468        atr_period: Period for ATR calculation (default: 10)
469        multiplier: ATR multiplier for bands (default: 3.0)
470
471    Returns:
472        DataFrame with columns:
473        - Supertrend: The Supertrend line value
474        - Supertrend_Direction: 1 for bullish, -1 for bearish
475        - Supertrend_Upper: Upper band
476        - Supertrend_Lower: Lower band
477    """
478    required = ["High", "Low", "Close"]
479    if not all(col in df.columns for col in required):
480        return pd.DataFrame(
481            {
482                "Supertrend": np.nan,
483                "Supertrend_Direction": np.nan,
484                "Supertrend_Upper": np.nan,
485                "Supertrend_Lower": np.nan,
486            },
487            index=df.index,
488        )
489
490    high = df["High"]
491    low = df["Low"]
492    close = df["Close"]
493
494    # Calculate ATR
495    tr1 = high - low
496    tr2 = abs(high - close.shift(1))
497    tr3 = abs(low - close.shift(1))
498    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
499    atr = tr.ewm(alpha=1 / atr_period, adjust=False).mean()
500
501    # Calculate basic bands
502    hl2 = (high + low) / 2
503    basic_upper = hl2 + (multiplier * atr)
504    basic_lower = hl2 - (multiplier * atr)
505
506    # Initialize arrays
507    n = len(df)
508    supertrend = np.zeros(n)
509    direction = np.zeros(n)
510    final_upper = np.zeros(n)
511    final_lower = np.zeros(n)
512
513    # First value
514    final_upper[0] = basic_upper.iloc[0]
515    final_lower[0] = basic_lower.iloc[0]
516    supertrend[0] = basic_upper.iloc[0]
517    direction[0] = -1  # Start bearish
518
519    # Calculate Supertrend
520    for i in range(1, n):
521        # Final Upper Band
522        if basic_upper.iloc[i] < final_upper[i - 1] or close.iloc[i - 1] > final_upper[i - 1]:
523            final_upper[i] = basic_upper.iloc[i]
524        else:
525            final_upper[i] = final_upper[i - 1]
526
527        # Final Lower Band
528        if basic_lower.iloc[i] > final_lower[i - 1] or close.iloc[i - 1] < final_lower[i - 1]:
529            final_lower[i] = basic_lower.iloc[i]
530        else:
531            final_lower[i] = final_lower[i - 1]
532
533        # Supertrend and Direction
534        if supertrend[i - 1] == final_upper[i - 1]:
535            # Was bearish
536            if close.iloc[i] > final_upper[i]:
537                supertrend[i] = final_lower[i]
538                direction[i] = 1  # Bullish
539            else:
540                supertrend[i] = final_upper[i]
541                direction[i] = -1  # Bearish
542        else:
543            # Was bullish
544            if close.iloc[i] < final_lower[i]:
545                supertrend[i] = final_upper[i]
546                direction[i] = -1  # Bearish
547            else:
548                supertrend[i] = final_lower[i]
549                direction[i] = 1  # Bullish
550
551    return pd.DataFrame(
552        {
553            "Supertrend": supertrend,
554            "Supertrend_Direction": direction,
555            "Supertrend_Upper": final_upper,
556            "Supertrend_Lower": final_lower,
557        },
558        index=df.index,
559    )

Calculate Supertrend indicator.

Supertrend is a trend-following indicator based on ATR.

  • When price is above Supertrend line: Bullish (uptrend)
  • When price is below Supertrend line: Bearish (downtrend)

Args: df: DataFrame with High, Low, Close columns atr_period: Period for ATR calculation (default: 10) multiplier: ATR multiplier for bands (default: 3.0)

Returns: DataFrame with columns: - Supertrend: The Supertrend line value - Supertrend_Direction: 1 for bullish, -1 for bearish - Supertrend_Upper: Upper band - Supertrend_Lower: Lower band

def calculate_tilson_t3( df: pandas.core.frame.DataFrame, period: int = 5, vfactor: float = 0.7, column: str = 'Close') -> pandas.core.series.Series:
100def calculate_tilson_t3(
101    df: pd.DataFrame,
102    period: int = 5,
103    vfactor: float = 0.7,
104    column: str = "Close",
105) -> pd.Series:
106    """Calculate Tilson T3 Moving Average.
107
108    T3 is a triple-smoothed exponential moving average that reduces lag
109    while maintaining smoothness. Developed by Tim Tilson.
110
111    The T3 uses a volume factor (vfactor) to control the amount of
112    smoothing vs responsiveness:
113    - vfactor = 0: T3 behaves like a triple EMA
114    - vfactor = 1: Maximum smoothing (may overshoot)
115    - vfactor = 0.7: Tilson's recommended default
116
117    Args:
118        df: DataFrame with price data
119        period: Number of periods for EMA calculations (default 5)
120        vfactor: Volume factor for smoothing (0-1, default 0.7)
121        column: Column name to use for calculation
122
123    Returns:
124        Series with T3 values
125
126    Examples:
127        >>> t3 = calculate_tilson_t3(df, period=5, vfactor=0.7)
128        >>> # More responsive (less smooth)
129        >>> t3_fast = calculate_tilson_t3(df, period=5, vfactor=0.5)
130        >>> # More smooth (more lag)
131        >>> t3_smooth = calculate_tilson_t3(df, period=5, vfactor=0.9)
132    """
133    col = _get_price_column(df, column)
134    if col not in df.columns:
135        return pd.Series(np.nan, index=df.index, name=f"T3_{period}")
136
137    # Calculate coefficients
138    c1 = -(vfactor**3)
139    c2 = 3 * vfactor**2 + 3 * vfactor**3
140    c3 = -6 * vfactor**2 - 3 * vfactor - 3 * vfactor**3
141    c4 = 1 + 3 * vfactor + vfactor**3 + 3 * vfactor**2
142
143    # Calculate 6 consecutive EMAs
144    ema1 = df[col].ewm(span=period, adjust=False).mean()
145    ema2 = ema1.ewm(span=period, adjust=False).mean()
146    ema3 = ema2.ewm(span=period, adjust=False).mean()
147    ema4 = ema3.ewm(span=period, adjust=False).mean()
148    ema5 = ema4.ewm(span=period, adjust=False).mean()
149    ema6 = ema5.ewm(span=period, adjust=False).mean()
150
151    # T3 = c1*e6 + c2*e5 + c3*e4 + c4*e3
152    t3 = c1 * ema6 + c2 * ema5 + c3 * ema4 + c4 * ema3
153
154    return t3.rename(f"T3_{period}")

Calculate Tilson T3 Moving Average.

T3 is a triple-smoothed exponential moving average that reduces lag while maintaining smoothness. Developed by Tim Tilson.

The T3 uses a volume factor (vfactor) to control the amount of smoothing vs responsiveness:

  • vfactor = 0: T3 behaves like a triple EMA
  • vfactor = 1: Maximum smoothing (may overshoot)
  • vfactor = 0.7: Tilson's recommended default

Args: df: DataFrame with price data period: Number of periods for EMA calculations (default 5) vfactor: Volume factor for smoothing (0-1, default 0.7) column: Column name to use for calculation

Returns: Series with T3 values

Examples:

t3 = calculate_tilson_t3(df, period=5, vfactor=0.7)

More responsive (less smooth)

t3_fast = calculate_tilson_t3(df, period=5, vfactor=0.5)

More smooth (more lag)

t3_smooth = calculate_tilson_t3(df, period=5, vfactor=0.9)

def calculate_hhv( df: pandas.core.frame.DataFrame, period: int = 14, column: str = 'High') -> pandas.core.series.Series:
567def calculate_hhv(
568    df: pd.DataFrame, period: int = 14, column: str = "High"
569) -> pd.Series:
570    """Calculate Highest High Value (HHV) - MetaStock indicator.
571
572    Returns the highest value of a column over a rolling window.
573
574    Args:
575        df: DataFrame with price data
576        period: Lookback period (default 14)
577        column: Column to use (default "High")
578
579    Returns:
580        Series with HHV values
581    """
582    col = _get_price_column(df, column)
583    if col not in df.columns:
584        return pd.Series(np.nan, index=df.index, name=f"HHV_{period}")
585    return df[col].rolling(window=period, min_periods=1).max()

Calculate Highest High Value (HHV) - MetaStock indicator.

Returns the highest value of a column over a rolling window.

Args: df: DataFrame with price data period: Lookback period (default 14) column: Column to use (default "High")

Returns: Series with HHV values

def calculate_llv( df: pandas.core.frame.DataFrame, period: int = 14, column: str = 'Low') -> pandas.core.series.Series:
588def calculate_llv(
589    df: pd.DataFrame, period: int = 14, column: str = "Low"
590) -> pd.Series:
591    """Calculate Lowest Low Value (LLV) - MetaStock indicator.
592
593    Returns the lowest value of a column over a rolling window.
594
595    Args:
596        df: DataFrame with price data
597        period: Lookback period (default 14)
598        column: Column to use (default "Low")
599
600    Returns:
601        Series with LLV values
602    """
603    col = _get_price_column(df, column)
604    if col not in df.columns:
605        return pd.Series(np.nan, index=df.index, name=f"LLV_{period}")
606    return df[col].rolling(window=period, min_periods=1).min()

Calculate Lowest Low Value (LLV) - MetaStock indicator.

Returns the lowest value of a column over a rolling window.

Args: df: DataFrame with price data period: Lookback period (default 14) column: Column to use (default "Low")

Returns: Series with LLV values

def calculate_mom( df: pandas.core.frame.DataFrame, period: int = 10, column: str = 'Close') -> pandas.core.series.Series:
609def calculate_mom(
610    df: pd.DataFrame, period: int = 10, column: str = "Close"
611) -> pd.Series:
612    """Calculate Momentum (MOM) - MetaStock indicator.
613
614    MOM = Close - Close[N periods ago]
615
616    Args:
617        df: DataFrame with price data
618        period: Lookback period (default 10)
619        column: Column to use (default "Close")
620
621    Returns:
622        Series with Momentum values
623    """
624    col = _get_price_column(df, column)
625    if col not in df.columns:
626        return pd.Series(np.nan, index=df.index, name=f"MOM_{period}")
627    return df[col] - df[col].shift(period)

Calculate Momentum (MOM) - MetaStock indicator.

MOM = Close - Close[N periods ago]

Args: df: DataFrame with price data period: Lookback period (default 10) column: Column to use (default "Close")

Returns: Series with Momentum values

def calculate_roc( df: pandas.core.frame.DataFrame, period: int = 10, column: str = 'Close') -> pandas.core.series.Series:
630def calculate_roc(
631    df: pd.DataFrame, period: int = 10, column: str = "Close"
632) -> pd.Series:
633    """Calculate Rate of Change (ROC) - MetaStock indicator.
634
635    ROC = ((Close - Close[N]) / Close[N]) * 100
636
637    Args:
638        df: DataFrame with price data
639        period: Lookback period (default 10)
640        column: Column to use (default "Close")
641
642    Returns:
643        Series with ROC values (percentage)
644    """
645    col = _get_price_column(df, column)
646    if col not in df.columns:
647        return pd.Series(np.nan, index=df.index, name=f"ROC_{period}")
648    shifted = df[col].shift(period)
649    return ((df[col] - shifted) / shifted) * 100

Calculate Rate of Change (ROC) - MetaStock indicator.

ROC = ((Close - Close[N]) / Close[N]) * 100

Args: df: DataFrame with price data period: Lookback period (default 10) column: Column to use (default "Close")

Returns: Series with ROC values (percentage)

def calculate_wma( df: pandas.core.frame.DataFrame, period: int = 20, column: str = 'Close') -> pandas.core.series.Series:
652def calculate_wma(
653    df: pd.DataFrame, period: int = 20, column: str = "Close"
654) -> pd.Series:
655    """Calculate Weighted Moving Average (WMA) - MetaStock indicator.
656
657    WMA assigns linearly increasing weights to recent data.
658    Weight for period i = i (most recent gets highest weight).
659
660    Args:
661        df: DataFrame with price data
662        period: Number of periods (default 20)
663        column: Column to use (default "Close")
664
665    Returns:
666        Series with WMA values
667    """
668    col = _get_price_column(df, column)
669    if col not in df.columns:
670        return pd.Series(np.nan, index=df.index, name=f"WMA_{period}")
671    weights = np.arange(1, period + 1, dtype=float)
672    return df[col].rolling(window=period, min_periods=period).apply(
673        lambda x: np.dot(x, weights) / weights.sum(), raw=True
674    )

Calculate Weighted Moving Average (WMA) - MetaStock indicator.

WMA assigns linearly increasing weights to recent data. Weight for period i = i (most recent gets highest weight).

Args: df: DataFrame with price data period: Number of periods (default 20) column: Column to use (default "Close")

Returns: Series with WMA values

def calculate_dema( df: pandas.core.frame.DataFrame, period: int = 20, column: str = 'Close') -> pandas.core.series.Series:
677def calculate_dema(
678    df: pd.DataFrame, period: int = 20, column: str = "Close"
679) -> pd.Series:
680    """Calculate Double Exponential Moving Average (DEMA) - MetaStock indicator.
681
682    DEMA = 2 * EMA(Close, N) - EMA(EMA(Close, N), N)
683
684    Args:
685        df: DataFrame with price data
686        period: Number of periods (default 20)
687        column: Column to use (default "Close")
688
689    Returns:
690        Series with DEMA values
691    """
692    col = _get_price_column(df, column)
693    if col not in df.columns:
694        return pd.Series(np.nan, index=df.index, name=f"DEMA_{period}")
695    ema1 = df[col].ewm(span=period, adjust=False).mean()
696    ema2 = ema1.ewm(span=period, adjust=False).mean()
697    return 2 * ema1 - ema2

Calculate Double Exponential Moving Average (DEMA) - MetaStock indicator.

DEMA = 2 * EMA(Close, N) - EMA(EMA(Close, N), N)

Args: df: DataFrame with price data period: Number of periods (default 20) column: Column to use (default "Close")

Returns: Series with DEMA values

def calculate_tema( df: pandas.core.frame.DataFrame, period: int = 20, column: str = 'Close') -> pandas.core.series.Series:
700def calculate_tema(
701    df: pd.DataFrame, period: int = 20, column: str = "Close"
702) -> pd.Series:
703    """Calculate Triple Exponential Moving Average (TEMA) - MetaStock indicator.
704
705    TEMA = 3*EMA - 3*EMA(EMA) + EMA(EMA(EMA))
706
707    Args:
708        df: DataFrame with price data
709        period: Number of periods (default 20)
710        column: Column to use (default "Close")
711
712    Returns:
713        Series with TEMA values
714    """
715    col = _get_price_column(df, column)
716    if col not in df.columns:
717        return pd.Series(np.nan, index=df.index, name=f"TEMA_{period}")
718    ema1 = df[col].ewm(span=period, adjust=False).mean()
719    ema2 = ema1.ewm(span=period, adjust=False).mean()
720    ema3 = ema2.ewm(span=period, adjust=False).mean()
721    return 3 * ema1 - 3 * ema2 + ema3

Calculate Triple Exponential Moving Average (TEMA) - MetaStock indicator.

TEMA = 3EMA - 3EMA(EMA) + EMA(EMA(EMA))

Args: df: DataFrame with price data period: Number of periods (default 20) column: Column to use (default "Close")

Returns: Series with TEMA values

def calculate_heikin_ashi(df: pandas.core.frame.DataFrame) -> pandas.core.frame.DataFrame:
 28def calculate_heikin_ashi(df: pd.DataFrame) -> pd.DataFrame:
 29    """Calculate Heikin Ashi candlestick values.
 30
 31    Heikin Ashi candles smooth price data and help identify trends more clearly.
 32    They use a modified formula that incorporates previous candle values.
 33
 34    Formulas:
 35        HA_Close = (Open + High + Low + Close) / 4
 36        HA_Open = (Previous_HA_Open + Previous_HA_Close) / 2
 37        HA_High = max(High, HA_Open, HA_Close)
 38        HA_Low = min(Low, HA_Open, HA_Close)
 39
 40    Note: First candle uses (Open + Close) / 2 for HA_Open since there's no previous.
 41
 42    Args:
 43        df: DataFrame with OHLC columns (Open, High, Low, Close).
 44            May also include Volume which will be preserved.
 45
 46    Returns:
 47        DataFrame with columns:
 48        - HA_Open: Heikin Ashi open price
 49        - HA_High: Heikin Ashi high price
 50        - HA_Low: Heikin Ashi low price
 51        - HA_Close: Heikin Ashi close price
 52        - Volume: Original volume (if present in input)
 53
 54    Examples:
 55        >>> import borsapy as bp
 56        >>> stock = bp.Ticker("THYAO")
 57        >>> df = stock.history(period="1mo")
 58        >>> ha = bp.calculate_heikin_ashi(df)
 59        >>> print(ha.tail())
 60                     HA_Open    HA_High     HA_Low   HA_Close    Volume
 61        Date
 62        2024-01-15   284.125   286.5000   283.2500   285.3750   1234567
 63        2024-01-16   284.750   287.0000   284.0000   286.1250   1345678
 64
 65    Raises:
 66        ValueError: If required OHLC columns are missing
 67
 68    See Also:
 69        - TechnicalMixin.heikin_ashi(): Method on Ticker/Index for direct calculation
 70        - TechnicalAnalyzer.heikin_ashi(): Method on TechnicalAnalyzer class
 71    """
 72    # Validate required columns
 73    required = ["Open", "High", "Low", "Close"]
 74    missing = [col for col in required if col not in df.columns]
 75    if missing:
 76        raise ValueError(f"Missing required columns: {missing}")
 77
 78    if df.empty:
 79        return pd.DataFrame(columns=["HA_Open", "HA_High", "HA_Low", "HA_Close", "Volume"])
 80
 81    # Calculate HA_Close: (O + H + L + C) / 4
 82    ha_close = (df["Open"] + df["High"] + df["Low"] + df["Close"]) / 4
 83
 84    # Calculate HA_Open iteratively (depends on previous HA values)
 85    ha_open = np.zeros(len(df))
 86
 87    # First candle: HA_Open = (Open + Close) / 2
 88    ha_open[0] = (df["Open"].iloc[0] + df["Close"].iloc[0]) / 2
 89
 90    # Subsequent candles: HA_Open = (Prev_HA_Open + Prev_HA_Close) / 2
 91    for i in range(1, len(df)):
 92        ha_open[i] = (ha_open[i - 1] + ha_close.iloc[i - 1]) / 2
 93
 94    # Convert to Series with proper index
 95    ha_open = pd.Series(ha_open, index=df.index)
 96
 97    # Calculate HA_High and HA_Low
 98    ha_high = pd.concat([df["High"], ha_open, ha_close], axis=1).max(axis=1)
 99    ha_low = pd.concat([df["Low"], ha_open, ha_close], axis=1).min(axis=1)
100
101    # Build result DataFrame
102    result = pd.DataFrame(
103        {
104            "HA_Open": ha_open,
105            "HA_High": ha_high,
106            "HA_Low": ha_low,
107            "HA_Close": ha_close,
108        },
109        index=df.index,
110    )
111
112    # Preserve Volume if present
113    if "Volume" in df.columns:
114        result["Volume"] = df["Volume"]
115
116    return result

Calculate Heikin Ashi candlestick values.

Heikin Ashi candles smooth price data and help identify trends more clearly. They use a modified formula that incorporates previous candle values.

Formulas: HA_Close = (Open + High + Low + Close) / 4 HA_Open = (Previous_HA_Open + Previous_HA_Close) / 2 HA_High = max(High, HA_Open, HA_Close) HA_Low = min(Low, HA_Open, HA_Close)

Note: First candle uses (Open + Close) / 2 for HA_Open since there's no previous.

Args: df: DataFrame with OHLC columns (Open, High, Low, Close). May also include Volume which will be preserved.

Returns: DataFrame with columns: - HA_Open: Heikin Ashi open price - HA_High: Heikin Ashi high price - HA_Low: Heikin Ashi low price - HA_Close: Heikin Ashi close price - Volume: Original volume (if present in input)

Examples:

import borsapy as bp stock = bp.Ticker("THYAO") df = stock.history(period="1mo") ha = bp.calculate_heikin_ashi(df) print(ha.tail()) HA_Open HA_High HA_Low HA_Close Volume Date 2024-01-15 284.125 286.5000 283.2500 285.3750 1234567 2024-01-16 284.750 287.0000 284.0000 286.1250 1345678

Raises: ValueError: If required OHLC columns are missing

See Also: - TechnicalMixin.heikin_ashi(): Method on Ticker/Index for direct calculation - TechnicalAnalyzer.heikin_ashi(): Method on TechnicalAnalyzer class

def create_replay( symbol: str, period: str = '1y', interval: str = '1d', speed: float = 1.0, realtime_injection: bool = False) -> ReplaySession:
413def create_replay(
414    symbol: str,
415    period: str = "1y",
416    interval: str = "1d",
417    speed: float = 1.0,
418    realtime_injection: bool = False,
419) -> ReplaySession:
420    """
421    Create a ReplaySession with historical data loaded automatically.
422
423    Convenience function that loads historical data from TradingView
424    and creates a ReplaySession.
425
426    Args:
427        symbol: Stock symbol (e.g., "THYAO", "GARAN")
428        period: Historical period to load.
429                Valid values: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, max
430        interval: Candle interval.
431                  Valid values: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk, 1mo
432        speed: Playback speed multiplier.
433               1.0 = real-time
434               10.0 = 10x speed
435               0.0 = no delay (as fast as possible)
436        realtime_injection: If True, delays between candles are based
437                           on actual time intervals.
438
439    Returns:
440        ReplaySession with loaded data.
441
442    Raises:
443        ValueError: If symbol not found or no data available.
444
445    Example:
446        >>> session = bp.create_replay("THYAO", period="1y", speed=100)
447        >>> for candle in session.replay():
448        ...     print(candle['close'])
449    """
450    # Import here to avoid circular import
451    from borsapy.ticker import Ticker
452
453    # Load historical data
454    ticker = Ticker(symbol)
455    df = ticker.history(period=period, interval=interval)
456
457    if df is None or len(df) == 0:
458        raise ValueError(f"No historical data available for {symbol}")
459
460    # Create session with loaded data
461    session = ReplaySession(
462        symbol=symbol,
463        df=df,
464        speed=speed,
465        realtime_injection=realtime_injection,
466    )
467
468    return session

Create a ReplaySession with historical data loaded automatically.

Convenience function that loads historical data from TradingView and creates a ReplaySession.

Args: symbol: Stock symbol (e.g., "THYAO", "GARAN") period: Historical period to load. Valid values: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, max interval: Candle interval. Valid values: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk, 1mo speed: Playback speed multiplier. 1.0 = real-time 10.0 = 10x speed 0.0 = no delay (as fast as possible) realtime_injection: If True, delays between candles are based on actual time intervals.

Returns: ReplaySession with loaded data.

Raises: ValueError: If symbol not found or no data available.

Example:

session = bp.create_replay("THYAO", period="1y", speed=100) for candle in session.replay(): ... print(candle['close'])

class BorsapyError(builtins.Exception):
5class BorsapyError(Exception):
6    """Base exception for all borsapy errors."""
7
8    pass

Base exception for all borsapy errors.

class TickerNotFoundError(borsapy.BorsapyError):
11class TickerNotFoundError(BorsapyError):
12    """Raised when a ticker symbol is not found."""
13
14    def __init__(self, symbol: str):
15        self.symbol = symbol
16        super().__init__(f"Ticker not found: {symbol}")

Raised when a ticker symbol is not found.

TickerNotFoundError(symbol: str)
14    def __init__(self, symbol: str):
15        self.symbol = symbol
16        super().__init__(f"Ticker not found: {symbol}")
symbol
class DataNotAvailableError(borsapy.BorsapyError):
19class DataNotAvailableError(BorsapyError):
20    """Raised when requested data is not available."""
21
22    def __init__(self, message: str = "Data not available"):
23        super().__init__(message)

Raised when requested data is not available.

DataNotAvailableError(message: str = 'Data not available')
22    def __init__(self, message: str = "Data not available"):
23        super().__init__(message)
class APIError(borsapy.BorsapyError):
26class APIError(BorsapyError):
27    """Raised when an API request fails."""
28
29    def __init__(self, message: str, status_code: int | None = None):
30        self.status_code = status_code
31        super().__init__(
32            f"API Error: {message}" + (f" (status: {status_code})" if status_code else "")
33        )

Raised when an API request fails.

APIError(message: str, status_code: int | None = None)
29    def __init__(self, message: str, status_code: int | None = None):
30        self.status_code = status_code
31        super().__init__(
32            f"API Error: {message}" + (f" (status: {status_code})" if status_code else "")
33        )
status_code
class AuthenticationError(borsapy.BorsapyError):
36class AuthenticationError(BorsapyError):
37    """Raised when authentication fails."""
38
39    pass

Raised when authentication fails.

class RateLimitError(borsapy.BorsapyError):
42class RateLimitError(BorsapyError):
43    """Raised when rate limit is exceeded."""
44
45    pass

Raised when rate limit is exceeded.

class InvalidPeriodError(borsapy.BorsapyError):
48class InvalidPeriodError(BorsapyError):
49    """Raised when an invalid period is specified."""
50
51    def __init__(self, period: str):
52        self.period = period
53        valid_periods = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"]
54        super().__init__(f"Invalid period: {period}. Valid periods: {', '.join(valid_periods)}")

Raised when an invalid period is specified.

InvalidPeriodError(period: str)
51    def __init__(self, period: str):
52        self.period = period
53        valid_periods = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"]
54        super().__init__(f"Invalid period: {period}. Valid periods: {', '.join(valid_periods)}")
period
class InvalidIntervalError(borsapy.BorsapyError):
57class InvalidIntervalError(BorsapyError):
58    """Raised when an invalid interval is specified."""
59
60    def __init__(self, interval: str):
61        self.interval = interval
62        valid_intervals = ["1m", "5m", "15m", "30m", "1h", "1d", "1wk", "1mo"]
63        super().__init__(
64            f"Invalid interval: {interval}. Valid intervals: {', '.join(valid_intervals)}"
65        )

Raised when an invalid interval is specified.

InvalidIntervalError(interval: str)
60    def __init__(self, interval: str):
61        self.interval = interval
62        valid_intervals = ["1m", "5m", "15m", "30m", "1h", "1d", "1wk", "1mo"]
63        super().__init__(
64            f"Invalid interval: {interval}. Valid intervals: {', '.join(valid_intervals)}"
65        )
interval
def set_tradingview_auth( username: str | None = None, password: str | None = None, session: str | None = None, session_sign: str | None = None) -> dict:
22def set_tradingview_auth(
23    username: str | None = None,
24    password: str | None = None,
25    session: str | None = None,
26    session_sign: str | None = None,
27) -> dict:
28    """
29    Set TradingView authentication credentials for real-time data access.
30
31    You can authenticate in two ways:
32    1. Username/password: Will perform login and get session tokens
33    2. Session tokens: Use existing sessionid and sessionid_sign cookies
34
35    Args:
36        username: TradingView username or email
37        password: TradingView password
38        session: Existing sessionid cookie value
39        session_sign: Existing sessionid_sign cookie value
40
41    Returns:
42        Dict with user info and session details
43
44    Examples:
45        >>> import borsapy as bp
46        >>> # Login with credentials
47        >>> bp.set_tradingview_auth(username="user@email.com", password="mypassword")
48        >>> # Or use existing session
49        >>> bp.set_tradingview_auth(session="abc123", session_sign="xyz789")
50        >>> # Now get real-time data
51        >>> stock = bp.Ticker("THYAO")
52        >>> stock.info["last"]  # Real-time price (no 15min delay)
53    """
54    global _auth_credentials
55
56    provider = get_tradingview_provider()
57
58    if username and password:
59        # Login with credentials
60        user_info = provider.login_user(username, password)
61        _auth_credentials = {
62            "session": user_info["session"],
63            "session_sign": user_info["session_sign"],
64            "auth_token": user_info.get("auth_token"),
65            "user": user_info,
66        }
67    elif session:
68        # Use existing session tokens
69        user_info = provider.get_user(session, session_sign or "")
70        _auth_credentials = {
71            "session": session,
72            "session_sign": session_sign or "",
73            "auth_token": user_info.get("auth_token"),
74            "user": user_info,
75        }
76    else:
77        raise ValueError("Provide either username/password or session/session_sign")
78
79    return _auth_credentials

Set TradingView authentication credentials for real-time data access.

You can authenticate in two ways:

  1. Username/password: Will perform login and get session tokens
  2. Session tokens: Use existing sessionid and sessionid_sign cookies

Args: username: TradingView username or email password: TradingView password session: Existing sessionid cookie value session_sign: Existing sessionid_sign cookie value

Returns: Dict with user info and session details

Examples:

import borsapy as bp

Login with credentials

bp.set_tradingview_auth(username="user@email.com", password="mypassword")

Or use existing session

bp.set_tradingview_auth(session="abc123", session_sign="xyz789")

Now get real-time data

stock = bp.Ticker("THYAO") stock.info["last"] # Real-time price (no 15min delay)

def get_tradingview_auth() -> dict | None:
88def get_tradingview_auth() -> dict | None:
89    """Get current TradingView authentication credentials."""
90    return _auth_credentials

Get current TradingView authentication credentials.

def clear_tradingview_auth() -> None:
82def clear_tradingview_auth() -> None:
83    """Clear TradingView authentication credentials."""
84    global _auth_credentials
85    _auth_credentials = None

Clear TradingView authentication credentials.

def create_stream(auth_token: str | None = None) -> TradingViewStream:
2144def create_stream(auth_token: str | None = None) -> TradingViewStream:
2145    """
2146    Create and return a TradingViewStream instance.
2147
2148    Args:
2149        auth_token: Optional auth token for real-time data.
2150
2151    Returns:
2152        TradingViewStream instance (not connected).
2153
2154    Example:
2155        >>> stream = bp.create_stream()
2156        >>> stream.connect()
2157    """
2158    return TradingViewStream(auth_token=auth_token)

Create and return a TradingViewStream instance.

Args: auth_token: Optional auth token for real-time data.

Returns: TradingViewStream instance (not connected).

Example:

stream = bp.create_stream() stream.connect()

class Backtest:
485class Backtest:
486    """
487    Backtest engine for evaluating trading strategies.
488
489    Runs a strategy function over historical data and calculates
490    comprehensive performance metrics.
491
492    Attributes:
493        symbol: Stock symbol to backtest.
494        strategy: Strategy function to evaluate.
495        period: Historical data period.
496        interval: Data interval (e.g., "1d", "1h").
497        capital: Initial capital.
498        commission: Commission rate per trade (e.g., 0.001 = 0.1%).
499        indicators: List of indicators to calculate.
500
501    Examples:
502        >>> def my_strategy(candle, position, indicators):
503        ...     if indicators['rsi'] < 30:
504        ...         return 'BUY'
505        ...     elif indicators['rsi'] > 70:
506        ...         return 'SELL'
507        ...     return 'HOLD'
508
509        >>> bt = Backtest("THYAO", my_strategy, period="1y")
510        >>> result = bt.run()
511        >>> print(result.sharpe_ratio)
512    """
513
514    # Indicator period warmup
515    WARMUP_PERIOD = 50
516
517    def __init__(
518        self,
519        symbol: str,
520        strategy: StrategyFunc,
521        period: str = "1y",
522        interval: str = "1d",
523        capital: float = 100_000.0,
524        commission: float = 0.001,
525        indicators: list[str] | None = None,
526        slippage: float = 0.0,  # Future use
527    ):
528        """
529        Initialize Backtest.
530
531        Args:
532            symbol: Stock symbol (e.g., "THYAO").
533            strategy: Strategy function with signature:
534                      strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
535            period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y).
536            interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d).
537            capital: Initial capital in TL.
538            commission: Commission rate per trade (0.001 = 0.1%).
539            indicators: List of indicators to calculate. Options:
540                       'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200',
541                       'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger',
542                       'atr', 'atr_20', 'stochastic', 'adx'
543            slippage: Slippage per trade (for future use).
544        """
545        self.symbol = symbol.upper()
546        self.strategy = strategy
547        self.period = period
548        self.interval = interval
549        self.capital = capital
550        self.commission = commission
551        self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"]
552        self.slippage = slippage
553
554        # Strategy name for reporting
555        self._strategy_name = getattr(strategy, "__name__", "custom_strategy")
556
557        # Data storage
558        self._df: pd.DataFrame | None = None
559        self._df_with_indicators: pd.DataFrame | None = None
560
561    def _load_data(self) -> pd.DataFrame:
562        """Load historical data from Ticker."""
563        from borsapy.ticker import Ticker
564
565        ticker = Ticker(self.symbol)
566        df = ticker.history(period=self.period, interval=self.interval)
567
568        if df is None or df.empty:
569            raise ValueError(f"No historical data available for {self.symbol}")
570
571        return df
572
573    def _calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
574        """Add indicator columns to DataFrame."""
575        from borsapy.technical import (
576            calculate_adx,
577            calculate_atr,
578            calculate_bollinger_bands,
579            calculate_ema,
580            calculate_macd,
581            calculate_rsi,
582            calculate_sma,
583            calculate_stochastic,
584        )
585
586        result = df.copy()
587
588        for ind in self.indicators:
589            ind_lower = ind.lower()
590
591            # RSI variants
592            if ind_lower == "rsi":
593                result["rsi"] = calculate_rsi(df, period=14)
594            elif ind_lower.startswith("rsi_"):
595                try:
596                    period = int(ind_lower.split("_")[1])
597                    result[f"rsi_{period}"] = calculate_rsi(df, period=period)
598                except (IndexError, ValueError):
599                    pass
600
601            # SMA variants
602            elif ind_lower.startswith("sma_"):
603                try:
604                    period = int(ind_lower.split("_")[1])
605                    result[f"sma_{period}"] = calculate_sma(df, period=period)
606                except (IndexError, ValueError):
607                    pass
608
609            # EMA variants
610            elif ind_lower.startswith("ema_"):
611                try:
612                    period = int(ind_lower.split("_")[1])
613                    result[f"ema_{period}"] = calculate_ema(df, period=period)
614                except (IndexError, ValueError):
615                    pass
616
617            # MACD
618            elif ind_lower == "macd":
619                macd_df = calculate_macd(df)
620                result["macd"] = macd_df["MACD"]
621                result["macd_signal"] = macd_df["Signal"]
622                result["macd_histogram"] = macd_df["Histogram"]
623
624            # Bollinger Bands
625            elif ind_lower in ("bollinger", "bb"):
626                bb_df = calculate_bollinger_bands(df)
627                result["bb_upper"] = bb_df["BB_Upper"]
628                result["bb_middle"] = bb_df["BB_Middle"]
629                result["bb_lower"] = bb_df["BB_Lower"]
630
631            # ATR variants
632            elif ind_lower == "atr":
633                result["atr"] = calculate_atr(df, period=14)
634            elif ind_lower.startswith("atr_"):
635                try:
636                    period = int(ind_lower.split("_")[1])
637                    result[f"atr_{period}"] = calculate_atr(df, period=period)
638                except (IndexError, ValueError):
639                    pass
640
641            # Stochastic
642            elif ind_lower in ("stochastic", "stoch"):
643                stoch_df = calculate_stochastic(df)
644                result["stoch_k"] = stoch_df["Stoch_K"]
645                result["stoch_d"] = stoch_df["Stoch_D"]
646
647            # ADX
648            elif ind_lower == "adx":
649                result["adx"] = calculate_adx(df, period=14)
650
651        return result
652
653    def _get_indicators_at(self, idx: int) -> dict[str, float]:
654        """Get indicator values at specific index."""
655        if self._df_with_indicators is None:
656            return {}
657
658        row = self._df_with_indicators.iloc[idx]
659        indicators = {}
660
661        # Extract all non-OHLCV columns as indicators
662        exclude_cols = {"Open", "High", "Low", "Close", "Volume", "Adj Close"}
663
664        for col in self._df_with_indicators.columns:
665            if col not in exclude_cols:
666                val = row[col]
667                if pd.notna(val):
668                    indicators[col] = float(val)
669
670        return indicators
671
672    def _build_candle(self, idx: int) -> dict[str, Any]:
673        """Build candle dict from DataFrame row."""
674        if self._df is None:
675            return {}
676
677        row = self._df.iloc[idx]
678        timestamp = self._df.index[idx]
679
680        if isinstance(timestamp, pd.Timestamp):
681            timestamp = timestamp.to_pydatetime()
682
683        return {
684            "timestamp": timestamp,
685            "open": float(row["Open"]),
686            "high": float(row["High"]),
687            "low": float(row["Low"]),
688            "close": float(row["Close"]),
689            "volume": float(row.get("Volume", 0)) if "Volume" in row else 0,
690            "_index": idx,
691        }
692
693    def run(self) -> BacktestResult:
694        """
695        Run the backtest.
696
697        Returns:
698            BacktestResult with all performance metrics.
699
700        Raises:
701            ValueError: If no data available for symbol.
702        """
703        # Load data
704        self._df = self._load_data()
705        self._df_with_indicators = self._calculate_indicators(self._df)
706
707        # Initialize state
708        cash = self.capital
709        position: Position = None
710        shares = 0.0
711        trades: list[Trade] = []
712        current_trade: Trade | None = None
713
714        # Track equity curve
715        equity_values = []
716        dates = []
717
718        # Buy & hold tracking
719        initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD]
720        bh_shares = self.capital / initial_price
721
722        # Run simulation
723        for idx in range(self.WARMUP_PERIOD, len(self._df)):
724            candle = self._build_candle(idx)
725            indicators = self._get_indicators_at(idx)
726            price = candle["close"]
727            timestamp = candle["timestamp"]
728
729            # Get strategy signal
730            try:
731                signal = self.strategy(candle, position, indicators)
732            except Exception:
733                signal = "HOLD"
734
735            # Execute trades
736            if signal == "BUY" and position is None:
737                # Calculate shares to buy (use all available cash)
738                entry_commission = cash * self.commission
739                available = cash - entry_commission
740                shares = available / price
741
742                current_trade = Trade(
743                    entry_time=timestamp,
744                    entry_price=price,
745                    side="long",
746                    shares=shares,
747                    commission=entry_commission,
748                )
749
750                cash = 0.0
751                position = "long"
752
753            elif signal == "SELL" and position == "long" and current_trade is not None:
754                # Close position
755                exit_value = shares * price
756                exit_commission = exit_value * self.commission
757
758                current_trade.exit_time = timestamp
759                current_trade.exit_price = price
760                current_trade.commission += exit_commission
761
762                trades.append(current_trade)
763
764                cash = exit_value - exit_commission
765                shares = 0.0
766                position = None
767                current_trade = None
768
769            # Track equity
770            if position == "long":
771                equity = shares * price
772            else:
773                equity = cash
774
775            equity_values.append(equity)
776            dates.append(timestamp)
777
778        # Close any open position at end
779        if position == "long" and current_trade is not None:
780            final_price = self._df["Close"].iloc[-1]
781            exit_value = shares * final_price
782            exit_commission = exit_value * self.commission
783
784            current_trade.exit_time = self._df.index[-1]
785            if isinstance(current_trade.exit_time, pd.Timestamp):
786                current_trade.exit_time = current_trade.exit_time.to_pydatetime()
787            current_trade.exit_price = final_price
788            current_trade.commission += exit_commission
789
790            trades.append(current_trade)
791
792        # Build curves
793        equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates))
794
795        # Calculate drawdown curve
796        running_max = equity_curve.cummax()
797        drawdown_curve = (equity_curve - running_max) / running_max
798
799        # Buy & hold curve
800        bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares
801        buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates))
802
803        return BacktestResult(
804            symbol=self.symbol,
805            period=self.period,
806            interval=self.interval,
807            strategy_name=self._strategy_name,
808            initial_capital=self.capital,
809            commission=self.commission,
810            trades=trades,
811            equity_curve=equity_curve,
812            drawdown_curve=drawdown_curve,
813            buy_hold_curve=buy_hold_curve,
814        )

Backtest engine for evaluating trading strategies.

Runs a strategy function over historical data and calculates comprehensive performance metrics.

Attributes: symbol: Stock symbol to backtest. strategy: Strategy function to evaluate. period: Historical data period. interval: Data interval (e.g., "1d", "1h"). capital: Initial capital. commission: Commission rate per trade (e.g., 0.001 = 0.1%). indicators: List of indicators to calculate.

Examples:

def my_strategy(candle, position, indicators): ... if indicators['rsi'] < 30: ... return 'BUY' ... elif indicators['rsi'] > 70: ... return 'SELL' ... return 'HOLD'

>>> bt = Backtest("THYAO", my_strategy, period="1y")
>>> result = bt.run()
>>> print(result.sharpe_ratio)
Backtest( symbol: str, strategy: Callable[[dict, typing.Optional[typing.Literal['long', 'short']], dict], typing.Optional[typing.Literal['BUY', 'SELL', 'HOLD']]], period: str = '1y', interval: str = '1d', capital: float = 100000.0, commission: float = 0.001, indicators: list[str] | None = None, slippage: float = 0.0)
517    def __init__(
518        self,
519        symbol: str,
520        strategy: StrategyFunc,
521        period: str = "1y",
522        interval: str = "1d",
523        capital: float = 100_000.0,
524        commission: float = 0.001,
525        indicators: list[str] | None = None,
526        slippage: float = 0.0,  # Future use
527    ):
528        """
529        Initialize Backtest.
530
531        Args:
532            symbol: Stock symbol (e.g., "THYAO").
533            strategy: Strategy function with signature:
534                      strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
535            period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y).
536            interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d).
537            capital: Initial capital in TL.
538            commission: Commission rate per trade (0.001 = 0.1%).
539            indicators: List of indicators to calculate. Options:
540                       'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200',
541                       'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger',
542                       'atr', 'atr_20', 'stochastic', 'adx'
543            slippage: Slippage per trade (for future use).
544        """
545        self.symbol = symbol.upper()
546        self.strategy = strategy
547        self.period = period
548        self.interval = interval
549        self.capital = capital
550        self.commission = commission
551        self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"]
552        self.slippage = slippage
553
554        # Strategy name for reporting
555        self._strategy_name = getattr(strategy, "__name__", "custom_strategy")
556
557        # Data storage
558        self._df: pd.DataFrame | None = None
559        self._df_with_indicators: pd.DataFrame | None = None

Initialize Backtest.

Args: symbol: Stock symbol (e.g., "THYAO"). strategy: Strategy function with signature: strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y). interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d). capital: Initial capital in TL. commission: Commission rate per trade (0.001 = 0.1%). indicators: List of indicators to calculate. Options: 'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200', 'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger', 'atr', 'atr_20', 'stochastic', 'adx' slippage: Slippage per trade (for future use).

WARMUP_PERIOD = 50
symbol
strategy
period
interval
capital
commission
indicators
slippage
def run(self) -> BacktestResult:
693    def run(self) -> BacktestResult:
694        """
695        Run the backtest.
696
697        Returns:
698            BacktestResult with all performance metrics.
699
700        Raises:
701            ValueError: If no data available for symbol.
702        """
703        # Load data
704        self._df = self._load_data()
705        self._df_with_indicators = self._calculate_indicators(self._df)
706
707        # Initialize state
708        cash = self.capital
709        position: Position = None
710        shares = 0.0
711        trades: list[Trade] = []
712        current_trade: Trade | None = None
713
714        # Track equity curve
715        equity_values = []
716        dates = []
717
718        # Buy & hold tracking
719        initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD]
720        bh_shares = self.capital / initial_price
721
722        # Run simulation
723        for idx in range(self.WARMUP_PERIOD, len(self._df)):
724            candle = self._build_candle(idx)
725            indicators = self._get_indicators_at(idx)
726            price = candle["close"]
727            timestamp = candle["timestamp"]
728
729            # Get strategy signal
730            try:
731                signal = self.strategy(candle, position, indicators)
732            except Exception:
733                signal = "HOLD"
734
735            # Execute trades
736            if signal == "BUY" and position is None:
737                # Calculate shares to buy (use all available cash)
738                entry_commission = cash * self.commission
739                available = cash - entry_commission
740                shares = available / price
741
742                current_trade = Trade(
743                    entry_time=timestamp,
744                    entry_price=price,
745                    side="long",
746                    shares=shares,
747                    commission=entry_commission,
748                )
749
750                cash = 0.0
751                position = "long"
752
753            elif signal == "SELL" and position == "long" and current_trade is not None:
754                # Close position
755                exit_value = shares * price
756                exit_commission = exit_value * self.commission
757
758                current_trade.exit_time = timestamp
759                current_trade.exit_price = price
760                current_trade.commission += exit_commission
761
762                trades.append(current_trade)
763
764                cash = exit_value - exit_commission
765                shares = 0.0
766                position = None
767                current_trade = None
768
769            # Track equity
770            if position == "long":
771                equity = shares * price
772            else:
773                equity = cash
774
775            equity_values.append(equity)
776            dates.append(timestamp)
777
778        # Close any open position at end
779        if position == "long" and current_trade is not None:
780            final_price = self._df["Close"].iloc[-1]
781            exit_value = shares * final_price
782            exit_commission = exit_value * self.commission
783
784            current_trade.exit_time = self._df.index[-1]
785            if isinstance(current_trade.exit_time, pd.Timestamp):
786                current_trade.exit_time = current_trade.exit_time.to_pydatetime()
787            current_trade.exit_price = final_price
788            current_trade.commission += exit_commission
789
790            trades.append(current_trade)
791
792        # Build curves
793        equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates))
794
795        # Calculate drawdown curve
796        running_max = equity_curve.cummax()
797        drawdown_curve = (equity_curve - running_max) / running_max
798
799        # Buy & hold curve
800        bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares
801        buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates))
802
803        return BacktestResult(
804            symbol=self.symbol,
805            period=self.period,
806            interval=self.interval,
807            strategy_name=self._strategy_name,
808            initial_capital=self.capital,
809            commission=self.commission,
810            trades=trades,
811            equity_curve=equity_curve,
812            drawdown_curve=drawdown_curve,
813            buy_hold_curve=buy_hold_curve,
814        )

Run the backtest.

Returns: BacktestResult with all performance metrics.

Raises: ValueError: If no data available for symbol.

@dataclass
class BacktestResult:
128@dataclass
129class BacktestResult:
130    """
131    Comprehensive backtest results with performance metrics.
132
133    Follows TradingView/Mathieu2301 result format for familiarity.
134
135    Attributes:
136        symbol: Traded symbol.
137        period: Test period (e.g., "1y").
138        interval: Data interval (e.g., "1d").
139        strategy_name: Name of the strategy function.
140        initial_capital: Starting capital.
141        commission: Commission rate used.
142        trades: List of executed trades.
143        equity_curve: Daily equity values.
144        drawdown_curve: Daily drawdown values.
145        buy_hold_curve: Buy & hold comparison values.
146    """
147
148    # Identification
149    symbol: str
150    period: str
151    interval: str
152    strategy_name: str
153
154    # Configuration
155    initial_capital: float
156    commission: float
157
158    # Results
159    trades: list[Trade] = field(default_factory=list)
160    equity_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
161    drawdown_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
162    buy_hold_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
163
164    # === Performance Properties ===
165
166    @property
167    def final_equity(self) -> float:
168        """Final portfolio value."""
169        if self.equity_curve.empty:
170            return self.initial_capital
171        return float(self.equity_curve.iloc[-1])
172
173    @property
174    def net_profit(self) -> float:
175        """Net profit in currency units."""
176        return self.final_equity - self.initial_capital
177
178    @property
179    def net_profit_pct(self) -> float:
180        """Net profit as percentage."""
181        if self.initial_capital == 0:
182            return 0.0
183        return (self.net_profit / self.initial_capital) * 100
184
185    @property
186    def total_trades(self) -> int:
187        """Total number of closed trades."""
188        return len([t for t in self.trades if t.is_closed])
189
190    @property
191    def winning_trades(self) -> int:
192        """Number of profitable trades."""
193        return len([t for t in self.trades if t.is_closed and (t.profit or 0) > 0])
194
195    @property
196    def losing_trades(self) -> int:
197        """Number of losing trades."""
198        return len([t for t in self.trades if t.is_closed and (t.profit or 0) <= 0])
199
200    @property
201    def win_rate(self) -> float:
202        """Percentage of winning trades."""
203        if self.total_trades == 0:
204            return 0.0
205        return (self.winning_trades / self.total_trades) * 100
206
207    @property
208    def profit_factor(self) -> float:
209        """Ratio of gross profits to gross losses."""
210        gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0)
211        gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0))
212        if gross_loss == 0:
213            return float("inf") if gross_profit > 0 else 0.0
214        return gross_profit / gross_loss
215
216    @property
217    def avg_trade(self) -> float:
218        """Average profit per trade."""
219        closed = [t for t in self.trades if t.is_closed]
220        if not closed:
221            return 0.0
222        return sum(t.profit or 0 for t in closed) / len(closed)
223
224    @property
225    def avg_winning_trade(self) -> float:
226        """Average profit of winning trades."""
227        winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0]
228        if not winners:
229            return 0.0
230        return sum(t.profit or 0 for t in winners) / len(winners)
231
232    @property
233    def avg_losing_trade(self) -> float:
234        """Average loss of losing trades."""
235        losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0]
236        if not losers:
237            return 0.0
238        return sum(t.profit or 0 for t in losers) / len(losers)
239
240    @property
241    def max_consecutive_wins(self) -> int:
242        """Maximum consecutive winning trades."""
243        return self._max_consecutive(lambda t: (t.profit or 0) > 0)
244
245    @property
246    def max_consecutive_losses(self) -> int:
247        """Maximum consecutive losing trades."""
248        return self._max_consecutive(lambda t: (t.profit or 0) <= 0)
249
250    def _max_consecutive(self, condition: Callable[[Trade], bool]) -> int:
251        """Helper to find max consecutive trades matching condition."""
252        closed = [t for t in self.trades if t.is_closed]
253        if not closed:
254            return 0
255        max_count = 0
256        current_count = 0
257        for trade in closed:
258            if condition(trade):
259                current_count += 1
260                max_count = max(max_count, current_count)
261            else:
262                current_count = 0
263        return max_count
264
265    @property
266    def sharpe_ratio(self) -> float:
267        """
268        Sharpe ratio (risk-adjusted return).
269
270        Assumes 252 trading days and risk-free rate from current 10Y bond.
271        """
272        if self.equity_curve.empty or len(self.equity_curve) < 2:
273            return float("nan")
274
275        returns = self.equity_curve.pct_change().dropna()
276        if returns.std() == 0:
277            return float("nan")
278
279        # Get risk-free rate
280        try:
281            from borsapy.bond import risk_free_rate
282
283            rf_annual = risk_free_rate()
284        except Exception:
285            rf_annual = 0.30  # Fallback 30%
286
287        rf_daily = rf_annual / 252
288        excess_returns = returns - rf_daily
289        return float(np.sqrt(252) * excess_returns.mean() / excess_returns.std())
290
291    @property
292    def sortino_ratio(self) -> float:
293        """
294        Sortino ratio (downside risk-adjusted return).
295
296        Uses downside deviation instead of standard deviation.
297        """
298        if self.equity_curve.empty or len(self.equity_curve) < 2:
299            return float("nan")
300
301        returns = self.equity_curve.pct_change().dropna()
302
303        # Get risk-free rate
304        try:
305            from borsapy.bond import risk_free_rate
306
307            rf_annual = risk_free_rate()
308        except Exception:
309            rf_annual = 0.30
310
311        rf_daily = rf_annual / 252
312        excess_returns = returns - rf_daily
313        negative_returns = excess_returns[excess_returns < 0]
314
315        if len(negative_returns) == 0 or negative_returns.std() == 0:
316            return float("inf") if excess_returns.mean() > 0 else float("nan")
317
318        downside_std = negative_returns.std()
319        return float(np.sqrt(252) * excess_returns.mean() / downside_std)
320
321    @property
322    def max_drawdown(self) -> float:
323        """Maximum drawdown as percentage."""
324        if self.drawdown_curve.empty:
325            return 0.0
326        return float(self.drawdown_curve.min()) * 100
327
328    @property
329    def max_drawdown_duration(self) -> int:
330        """Maximum drawdown duration in days."""
331        if self.equity_curve.empty:
332            return 0
333
334        # Find periods where we're in drawdown
335        running_max = self.equity_curve.cummax()
336        in_drawdown = self.equity_curve < running_max
337
338        max_duration = 0
339        current_duration = 0
340
341        for is_dd in in_drawdown:
342            if is_dd:
343                current_duration += 1
344                max_duration = max(max_duration, current_duration)
345            else:
346                current_duration = 0
347
348        return max_duration
349
350    @property
351    def buy_hold_return(self) -> float:
352        """Buy & hold return as percentage."""
353        if self.buy_hold_curve.empty:
354            return 0.0
355        first = self.buy_hold_curve.iloc[0]
356        last = self.buy_hold_curve.iloc[-1]
357        if first == 0:
358            return 0.0
359        return ((last - first) / first) * 100
360
361    @property
362    def vs_buy_hold(self) -> float:
363        """Strategy outperformance vs buy & hold (percentage points)."""
364        return self.net_profit_pct - self.buy_hold_return
365
366    @property
367    def calmar_ratio(self) -> float:
368        """Calmar ratio (annualized return / max drawdown)."""
369        if self.max_drawdown == 0:
370            return float("inf") if self.net_profit_pct > 0 else 0.0
371        # Annualize return (assuming 252 trading days)
372        trading_days = len(self.equity_curve)
373        if trading_days == 0:
374            return 0.0
375        annual_return = self.net_profit_pct * (252 / trading_days)
376        return annual_return / abs(self.max_drawdown)
377
378    # === Export Methods ===
379
380    @property
381    def trades_df(self) -> pd.DataFrame:
382        """Get trades as DataFrame."""
383        if not self.trades:
384            return pd.DataFrame(
385                columns=[
386                    "entry_time",
387                    "entry_price",
388                    "exit_time",
389                    "exit_price",
390                    "side",
391                    "shares",
392                    "commission",
393                    "profit",
394                    "profit_pct",
395                    "duration",
396                ]
397            )
398        return pd.DataFrame([t.to_dict() for t in self.trades])
399
400    def to_dict(self) -> dict[str, Any]:
401        """
402        Export results to dictionary.
403
404        Compatible with TradingView/Mathieu2301 format.
405        """
406        return {
407            # Identification
408            "symbol": self.symbol,
409            "period": self.period,
410            "interval": self.interval,
411            "strategy_name": self.strategy_name,
412            # Configuration
413            "initial_capital": self.initial_capital,
414            "commission": self.commission,
415            # Summary
416            "net_profit": round(self.net_profit, 2),
417            "net_profit_pct": round(self.net_profit_pct, 2),
418            "final_equity": round(self.final_equity, 2),
419            # Trade Statistics
420            "total_trades": self.total_trades,
421            "winning_trades": self.winning_trades,
422            "losing_trades": self.losing_trades,
423            "win_rate": round(self.win_rate, 2),
424            "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf",
425            "avg_trade": round(self.avg_trade, 2),
426            "avg_winning_trade": round(self.avg_winning_trade, 2),
427            "avg_losing_trade": round(self.avg_losing_trade, 2),
428            "max_consecutive_wins": self.max_consecutive_wins,
429            "max_consecutive_losses": self.max_consecutive_losses,
430            # Risk Metrics
431            "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None,
432            "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None,
433            "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None,
434            "max_drawdown": round(self.max_drawdown, 2),
435            "max_drawdown_duration": self.max_drawdown_duration,
436            # Comparison
437            "buy_hold_return": round(self.buy_hold_return, 2),
438            "vs_buy_hold": round(self.vs_buy_hold, 2),
439        }
440
441    def summary(self) -> str:
442        """
443        Generate human-readable performance summary.
444
445        Returns:
446            Formatted summary string.
447        """
448        d = self.to_dict()
449
450        lines = [
451            "=" * 60,
452            f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})",
453            "=" * 60,
454            f"Period: {d['period']} | Interval: {d['interval']}",
455            f"Initial Capital: {d['initial_capital']:,.2f} TL",
456            f"Commission: {d['commission']*100:.2f}%",
457            "",
458            "--- PERFORMANCE ---",
459            f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)",
460            f"Final Equity: {d['final_equity']:,.2f} TL",
461            f"Buy & Hold: {d['buy_hold_return']:+.2f}%",
462            f"vs B&H: {d['vs_buy_hold']:+.2f}%",
463            "",
464            "--- TRADE STATISTICS ---",
465            f"Total Trades: {d['total_trades']}",
466            f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}",
467            f"Win Rate: {d['win_rate']:.1f}%",
468            f"Profit Factor: {d['profit_factor']}",
469            f"Avg Trade: {d['avg_trade']:,.2f} TL",
470            f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL",
471            f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}",
472            "",
473            "--- RISK METRICS ---",
474            f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}",
475            f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}",
476            f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}",
477            f"Max Drawdown: {d['max_drawdown']:.2f}%",
478            f"Max DD Duration: {d['max_drawdown_duration']} days",
479            "=" * 60,
480        ]
481
482        return "\n".join(lines)

Comprehensive backtest results with performance metrics.

Follows TradingView/Mathieu2301 result format for familiarity.

Attributes: symbol: Traded symbol. period: Test period (e.g., "1y"). interval: Data interval (e.g., "1d"). strategy_name: Name of the strategy function. initial_capital: Starting capital. commission: Commission rate used. trades: List of executed trades. equity_curve: Daily equity values. drawdown_curve: Daily drawdown values. buy_hold_curve: Buy & hold comparison values.

BacktestResult( symbol: str, period: str, interval: str, strategy_name: str, initial_capital: float, commission: float, trades: list[Trade] = <factory>, equity_curve: pandas.core.series.Series = <factory>, drawdown_curve: pandas.core.series.Series = <factory>, buy_hold_curve: pandas.core.series.Series = <factory>)
symbol: str
period: str
interval: str
strategy_name: str
initial_capital: float
commission: float
trades: list[Trade]
equity_curve: pandas.core.series.Series
drawdown_curve: pandas.core.series.Series
buy_hold_curve: pandas.core.series.Series
final_equity: float
166    @property
167    def final_equity(self) -> float:
168        """Final portfolio value."""
169        if self.equity_curve.empty:
170            return self.initial_capital
171        return float(self.equity_curve.iloc[-1])

Final portfolio value.

net_profit: float
173    @property
174    def net_profit(self) -> float:
175        """Net profit in currency units."""
176        return self.final_equity - self.initial_capital

Net profit in currency units.

net_profit_pct: float
178    @property
179    def net_profit_pct(self) -> float:
180        """Net profit as percentage."""
181        if self.initial_capital == 0:
182            return 0.0
183        return (self.net_profit / self.initial_capital) * 100

Net profit as percentage.

total_trades: int
185    @property
186    def total_trades(self) -> int:
187        """Total number of closed trades."""
188        return len([t for t in self.trades if t.is_closed])

Total number of closed trades.

winning_trades: int
190    @property
191    def winning_trades(self) -> int:
192        """Number of profitable trades."""
193        return len([t for t in self.trades if t.is_closed and (t.profit or 0) > 0])

Number of profitable trades.

losing_trades: int
195    @property
196    def losing_trades(self) -> int:
197        """Number of losing trades."""
198        return len([t for t in self.trades if t.is_closed and (t.profit or 0) <= 0])

Number of losing trades.

win_rate: float
200    @property
201    def win_rate(self) -> float:
202        """Percentage of winning trades."""
203        if self.total_trades == 0:
204            return 0.0
205        return (self.winning_trades / self.total_trades) * 100

Percentage of winning trades.

profit_factor: float
207    @property
208    def profit_factor(self) -> float:
209        """Ratio of gross profits to gross losses."""
210        gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0)
211        gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0))
212        if gross_loss == 0:
213            return float("inf") if gross_profit > 0 else 0.0
214        return gross_profit / gross_loss

Ratio of gross profits to gross losses.

avg_trade: float
216    @property
217    def avg_trade(self) -> float:
218        """Average profit per trade."""
219        closed = [t for t in self.trades if t.is_closed]
220        if not closed:
221            return 0.0
222        return sum(t.profit or 0 for t in closed) / len(closed)

Average profit per trade.

avg_winning_trade: float
224    @property
225    def avg_winning_trade(self) -> float:
226        """Average profit of winning trades."""
227        winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0]
228        if not winners:
229            return 0.0
230        return sum(t.profit or 0 for t in winners) / len(winners)

Average profit of winning trades.

avg_losing_trade: float
232    @property
233    def avg_losing_trade(self) -> float:
234        """Average loss of losing trades."""
235        losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0]
236        if not losers:
237            return 0.0
238        return sum(t.profit or 0 for t in losers) / len(losers)

Average loss of losing trades.

max_consecutive_wins: int
240    @property
241    def max_consecutive_wins(self) -> int:
242        """Maximum consecutive winning trades."""
243        return self._max_consecutive(lambda t: (t.profit or 0) > 0)

Maximum consecutive winning trades.

max_consecutive_losses: int
245    @property
246    def max_consecutive_losses(self) -> int:
247        """Maximum consecutive losing trades."""
248        return self._max_consecutive(lambda t: (t.profit or 0) <= 0)

Maximum consecutive losing trades.

sharpe_ratio: float
265    @property
266    def sharpe_ratio(self) -> float:
267        """
268        Sharpe ratio (risk-adjusted return).
269
270        Assumes 252 trading days and risk-free rate from current 10Y bond.
271        """
272        if self.equity_curve.empty or len(self.equity_curve) < 2:
273            return float("nan")
274
275        returns = self.equity_curve.pct_change().dropna()
276        if returns.std() == 0:
277            return float("nan")
278
279        # Get risk-free rate
280        try:
281            from borsapy.bond import risk_free_rate
282
283            rf_annual = risk_free_rate()
284        except Exception:
285            rf_annual = 0.30  # Fallback 30%
286
287        rf_daily = rf_annual / 252
288        excess_returns = returns - rf_daily
289        return float(np.sqrt(252) * excess_returns.mean() / excess_returns.std())

Sharpe ratio (risk-adjusted return).

Assumes 252 trading days and risk-free rate from current 10Y bond.

sortino_ratio: float
291    @property
292    def sortino_ratio(self) -> float:
293        """
294        Sortino ratio (downside risk-adjusted return).
295
296        Uses downside deviation instead of standard deviation.
297        """
298        if self.equity_curve.empty or len(self.equity_curve) < 2:
299            return float("nan")
300
301        returns = self.equity_curve.pct_change().dropna()
302
303        # Get risk-free rate
304        try:
305            from borsapy.bond import risk_free_rate
306
307            rf_annual = risk_free_rate()
308        except Exception:
309            rf_annual = 0.30
310
311        rf_daily = rf_annual / 252
312        excess_returns = returns - rf_daily
313        negative_returns = excess_returns[excess_returns < 0]
314
315        if len(negative_returns) == 0 or negative_returns.std() == 0:
316            return float("inf") if excess_returns.mean() > 0 else float("nan")
317
318        downside_std = negative_returns.std()
319        return float(np.sqrt(252) * excess_returns.mean() / downside_std)

Sortino ratio (downside risk-adjusted return).

Uses downside deviation instead of standard deviation.

max_drawdown: float
321    @property
322    def max_drawdown(self) -> float:
323        """Maximum drawdown as percentage."""
324        if self.drawdown_curve.empty:
325            return 0.0
326        return float(self.drawdown_curve.min()) * 100

Maximum drawdown as percentage.

max_drawdown_duration: int
328    @property
329    def max_drawdown_duration(self) -> int:
330        """Maximum drawdown duration in days."""
331        if self.equity_curve.empty:
332            return 0
333
334        # Find periods where we're in drawdown
335        running_max = self.equity_curve.cummax()
336        in_drawdown = self.equity_curve < running_max
337
338        max_duration = 0
339        current_duration = 0
340
341        for is_dd in in_drawdown:
342            if is_dd:
343                current_duration += 1
344                max_duration = max(max_duration, current_duration)
345            else:
346                current_duration = 0
347
348        return max_duration

Maximum drawdown duration in days.

buy_hold_return: float
350    @property
351    def buy_hold_return(self) -> float:
352        """Buy & hold return as percentage."""
353        if self.buy_hold_curve.empty:
354            return 0.0
355        first = self.buy_hold_curve.iloc[0]
356        last = self.buy_hold_curve.iloc[-1]
357        if first == 0:
358            return 0.0
359        return ((last - first) / first) * 100

Buy & hold return as percentage.

vs_buy_hold: float
361    @property
362    def vs_buy_hold(self) -> float:
363        """Strategy outperformance vs buy & hold (percentage points)."""
364        return self.net_profit_pct - self.buy_hold_return

Strategy outperformance vs buy & hold (percentage points).

calmar_ratio: float
366    @property
367    def calmar_ratio(self) -> float:
368        """Calmar ratio (annualized return / max drawdown)."""
369        if self.max_drawdown == 0:
370            return float("inf") if self.net_profit_pct > 0 else 0.0
371        # Annualize return (assuming 252 trading days)
372        trading_days = len(self.equity_curve)
373        if trading_days == 0:
374            return 0.0
375        annual_return = self.net_profit_pct * (252 / trading_days)
376        return annual_return / abs(self.max_drawdown)

Calmar ratio (annualized return / max drawdown).

trades_df: pandas.core.frame.DataFrame
380    @property
381    def trades_df(self) -> pd.DataFrame:
382        """Get trades as DataFrame."""
383        if not self.trades:
384            return pd.DataFrame(
385                columns=[
386                    "entry_time",
387                    "entry_price",
388                    "exit_time",
389                    "exit_price",
390                    "side",
391                    "shares",
392                    "commission",
393                    "profit",
394                    "profit_pct",
395                    "duration",
396                ]
397            )
398        return pd.DataFrame([t.to_dict() for t in self.trades])

Get trades as DataFrame.

def to_dict(self) -> dict[str, typing.Any]:
400    def to_dict(self) -> dict[str, Any]:
401        """
402        Export results to dictionary.
403
404        Compatible with TradingView/Mathieu2301 format.
405        """
406        return {
407            # Identification
408            "symbol": self.symbol,
409            "period": self.period,
410            "interval": self.interval,
411            "strategy_name": self.strategy_name,
412            # Configuration
413            "initial_capital": self.initial_capital,
414            "commission": self.commission,
415            # Summary
416            "net_profit": round(self.net_profit, 2),
417            "net_profit_pct": round(self.net_profit_pct, 2),
418            "final_equity": round(self.final_equity, 2),
419            # Trade Statistics
420            "total_trades": self.total_trades,
421            "winning_trades": self.winning_trades,
422            "losing_trades": self.losing_trades,
423            "win_rate": round(self.win_rate, 2),
424            "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf",
425            "avg_trade": round(self.avg_trade, 2),
426            "avg_winning_trade": round(self.avg_winning_trade, 2),
427            "avg_losing_trade": round(self.avg_losing_trade, 2),
428            "max_consecutive_wins": self.max_consecutive_wins,
429            "max_consecutive_losses": self.max_consecutive_losses,
430            # Risk Metrics
431            "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None,
432            "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None,
433            "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None,
434            "max_drawdown": round(self.max_drawdown, 2),
435            "max_drawdown_duration": self.max_drawdown_duration,
436            # Comparison
437            "buy_hold_return": round(self.buy_hold_return, 2),
438            "vs_buy_hold": round(self.vs_buy_hold, 2),
439        }

Export results to dictionary.

Compatible with TradingView/Mathieu2301 format.

def summary(self) -> str:
441    def summary(self) -> str:
442        """
443        Generate human-readable performance summary.
444
445        Returns:
446            Formatted summary string.
447        """
448        d = self.to_dict()
449
450        lines = [
451            "=" * 60,
452            f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})",
453            "=" * 60,
454            f"Period: {d['period']} | Interval: {d['interval']}",
455            f"Initial Capital: {d['initial_capital']:,.2f} TL",
456            f"Commission: {d['commission']*100:.2f}%",
457            "",
458            "--- PERFORMANCE ---",
459            f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)",
460            f"Final Equity: {d['final_equity']:,.2f} TL",
461            f"Buy & Hold: {d['buy_hold_return']:+.2f}%",
462            f"vs B&H: {d['vs_buy_hold']:+.2f}%",
463            "",
464            "--- TRADE STATISTICS ---",
465            f"Total Trades: {d['total_trades']}",
466            f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}",
467            f"Win Rate: {d['win_rate']:.1f}%",
468            f"Profit Factor: {d['profit_factor']}",
469            f"Avg Trade: {d['avg_trade']:,.2f} TL",
470            f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL",
471            f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}",
472            "",
473            "--- RISK METRICS ---",
474            f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}",
475            f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}",
476            f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}",
477            f"Max Drawdown: {d['max_drawdown']:.2f}%",
478            f"Max DD Duration: {d['max_drawdown_duration']} days",
479            "=" * 60,
480        ]
481
482        return "\n".join(lines)

Generate human-readable performance summary.

Returns: Formatted summary string.

@dataclass
class Trade:
 52@dataclass
 53class Trade:
 54    """
 55    Represents a single trade in a backtest.
 56
 57    Attributes:
 58        entry_time: When the trade was opened.
 59        entry_price: Price at entry.
 60        exit_time: When the trade was closed (None if open).
 61        exit_price: Price at exit (None if open).
 62        side: Trade direction ('long' or 'short').
 63        shares: Number of shares traded.
 64        commission: Total commission paid (entry + exit).
 65    """
 66
 67    entry_time: datetime
 68    entry_price: float
 69    exit_time: datetime | None = None
 70    exit_price: float | None = None
 71    side: Literal["long", "short"] = "long"
 72    shares: float = 0.0
 73    commission: float = 0.0
 74
 75    @property
 76    def is_closed(self) -> bool:
 77        """Check if trade is closed."""
 78        return self.exit_time is not None and self.exit_price is not None
 79
 80    @property
 81    def profit(self) -> float | None:
 82        """Calculate profit in currency units (None if open)."""
 83        if not self.is_closed:
 84            return None
 85        assert self.exit_price is not None
 86        if self.side == "long":
 87            gross = (self.exit_price - self.entry_price) * self.shares
 88        else:
 89            gross = (self.entry_price - self.exit_price) * self.shares
 90        return gross - self.commission
 91
 92    @property
 93    def profit_pct(self) -> float | None:
 94        """Calculate profit as percentage (None if open)."""
 95        if not self.is_closed or self.entry_price == 0:
 96            return None
 97        profit = self.profit
 98        if profit is None:
 99            return None
100        entry_value = self.entry_price * self.shares
101        return (profit / entry_value) * 100
102
103    @property
104    def duration(self) -> float | None:
105        """Trade duration in days (None if open)."""
106        if not self.is_closed:
107            return None
108        assert self.exit_time is not None
109        delta = self.exit_time - self.entry_time
110        return delta.total_seconds() / 86400  # Convert to days
111
112    def to_dict(self) -> dict[str, Any]:
113        """Convert trade to dictionary."""
114        return {
115            "entry_time": self.entry_time,
116            "entry_price": self.entry_price,
117            "exit_time": self.exit_time,
118            "exit_price": self.exit_price,
119            "side": self.side,
120            "shares": self.shares,
121            "commission": self.commission,
122            "profit": self.profit,
123            "profit_pct": self.profit_pct,
124            "duration": self.duration,
125        }

Represents a single trade in a backtest.

Attributes: entry_time: When the trade was opened. entry_price: Price at entry. exit_time: When the trade was closed (None if open). exit_price: Price at exit (None if open). side: Trade direction ('long' or 'short'). shares: Number of shares traded. commission: Total commission paid (entry + exit).

Trade( entry_time: datetime.datetime, entry_price: float, exit_time: datetime.datetime | None = None, exit_price: float | None = None, side: Literal['long', 'short'] = 'long', shares: float = 0.0, commission: float = 0.0)
entry_time: datetime.datetime
entry_price: float
exit_time: datetime.datetime | None = None
exit_price: float | None = None
side: Literal['long', 'short'] = 'long'
shares: float = 0.0
commission: float = 0.0
is_closed: bool
75    @property
76    def is_closed(self) -> bool:
77        """Check if trade is closed."""
78        return self.exit_time is not None and self.exit_price is not None

Check if trade is closed.

profit: float | None
80    @property
81    def profit(self) -> float | None:
82        """Calculate profit in currency units (None if open)."""
83        if not self.is_closed:
84            return None
85        assert self.exit_price is not None
86        if self.side == "long":
87            gross = (self.exit_price - self.entry_price) * self.shares
88        else:
89            gross = (self.entry_price - self.exit_price) * self.shares
90        return gross - self.commission

Calculate profit in currency units (None if open).

profit_pct: float | None
 92    @property
 93    def profit_pct(self) -> float | None:
 94        """Calculate profit as percentage (None if open)."""
 95        if not self.is_closed or self.entry_price == 0:
 96            return None
 97        profit = self.profit
 98        if profit is None:
 99            return None
100        entry_value = self.entry_price * self.shares
101        return (profit / entry_value) * 100

Calculate profit as percentage (None if open).

duration: float | None
103    @property
104    def duration(self) -> float | None:
105        """Trade duration in days (None if open)."""
106        if not self.is_closed:
107            return None
108        assert self.exit_time is not None
109        delta = self.exit_time - self.entry_time
110        return delta.total_seconds() / 86400  # Convert to days

Trade duration in days (None if open).

def to_dict(self) -> dict[str, typing.Any]:
112    def to_dict(self) -> dict[str, Any]:
113        """Convert trade to dictionary."""
114        return {
115            "entry_time": self.entry_time,
116            "entry_price": self.entry_price,
117            "exit_time": self.exit_time,
118            "exit_price": self.exit_price,
119            "side": self.side,
120            "shares": self.shares,
121            "commission": self.commission,
122            "profit": self.profit,
123            "profit_pct": self.profit_pct,
124            "duration": self.duration,
125        }

Convert trade to dictionary.

def backtest( symbol: str, strategy: Callable[[dict, typing.Optional[typing.Literal['long', 'short']], dict], typing.Optional[typing.Literal['BUY', 'SELL', 'HOLD']]], period: str = '1y', interval: str = '1d', capital: float = 100000.0, commission: float = 0.001, indicators: list[str] | None = None) -> BacktestResult:
817def backtest(
818    symbol: str,
819    strategy: StrategyFunc,
820    period: str = "1y",
821    interval: str = "1d",
822    capital: float = 100_000.0,
823    commission: float = 0.001,
824    indicators: list[str] | None = None,
825) -> BacktestResult:
826    """
827    Run a backtest with a single function call.
828
829    Convenience function that creates a Backtest instance and runs it.
830
831    Args:
832        symbol: Stock symbol (e.g., "THYAO").
833        strategy: Strategy function with signature:
834                  strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
835        period: Historical data period.
836        interval: Data interval.
837        capital: Initial capital.
838        commission: Commission rate.
839        indicators: List of indicators to calculate.
840
841    Returns:
842        BacktestResult with all performance metrics.
843
844    Examples:
845        >>> def rsi_strategy(candle, position, indicators):
846        ...     if indicators.get('rsi', 50) < 30 and position is None:
847        ...         return 'BUY'
848        ...     elif indicators.get('rsi', 50) > 70 and position == 'long':
849        ...         return 'SELL'
850        ...     return 'HOLD'
851
852        >>> result = bp.backtest("THYAO", rsi_strategy, period="1y")
853        >>> print(f"Net Profit: {result.net_profit_pct:.2f}%")
854        >>> print(f"Sharpe: {result.sharpe_ratio:.2f}")
855    """
856    bt = Backtest(
857        symbol=symbol,
858        strategy=strategy,
859        period=period,
860        interval=interval,
861        capital=capital,
862        commission=commission,
863        indicators=indicators,
864    )
865    return bt.run()

Run a backtest with a single function call.

Convenience function that creates a Backtest instance and runs it.

Args: symbol: Stock symbol (e.g., "THYAO"). strategy: Strategy function with signature: strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None period: Historical data period. interval: Data interval. capital: Initial capital. commission: Commission rate. indicators: List of indicators to calculate.

Returns: BacktestResult with all performance metrics.

Examples:

def rsi_strategy(candle, position, indicators): ... if indicators.get('rsi', 50) < 30 and position is None: ... return 'BUY' ... elif indicators.get('rsi', 50) > 70 and position == 'long': ... return 'SELL' ... return 'HOLD'

>>> result = bp.backtest("THYAO", rsi_strategy, period="1y")
>>> print(f"Net Profit: {result.net_profit_pct:.2f}%")
>>> print(f"Sharpe: {result.sharpe_ratio:.2f}")
def withholding_tax_rate( fund_code: str, purchase_date: datetime.date | str | None = None, holding_days: int | None = None) -> float | None:
248def withholding_tax_rate(
249    fund_code: str,
250    purchase_date: date | str | None = None,
251    holding_days: int | None = None,
252) -> float | None:
253    """Get the withholding tax rate for a specific fund.
254
255    Convenience wrapper that fetches fund info from TEFAS, classifies the
256    fund's tax category, and returns the applicable rate.
257
258    Args:
259        fund_code: TEFAS fund code (e.g., "AAK", "TTE").
260        purchase_date: Date of fund purchase. Defaults to today.
261        holding_days: Number of days held (relevant for GSYF/GYF funds).
262
263    Returns:
264        Tax rate as a decimal (e.g., 0.15 for 15%), or None if fund
265        category cannot be determined.
266
267    Examples:
268        >>> import borsapy as bp
269        >>> bp.withholding_tax_rate("AAK", "2025-06-01")
270        0.15
271        >>> bp.withholding_tax_rate("AAK", "2025-08-01")
272        0.175
273    """
274    from borsapy.fund import Fund
275
276    if purchase_date is None:
277        purchase_date = date.today()
278
279    fund = Fund(fund_code)
280    info = fund.info
281    category = info.get("category", "") or ""
282    fund_name = info.get("name", "") or ""
283
284    tax_cat = classify_fund_tax_category(category, fund_name)
285    if tax_cat is None:
286        return None
287
288    return get_withholding_tax_rate(tax_cat, purchase_date, holding_days)

Get the withholding tax rate for a specific fund.

Convenience wrapper that fetches fund info from TEFAS, classifies the fund's tax category, and returns the applicable rate.

Args: fund_code: TEFAS fund code (e.g., "AAK", "TTE"). purchase_date: Date of fund purchase. Defaults to today. holding_days: Number of days held (relevant for GSYF/GYF funds).

Returns: Tax rate as a decimal (e.g., 0.15 for 15%), or None if fund category cannot be determined.

Examples:

import borsapy as bp bp.withholding_tax_rate("AAK", "2025-06-01") 0.15 bp.withholding_tax_rate("AAK", "2025-08-01") 0.175

def withholding_tax_table() -> pandas.core.frame.DataFrame:
291def withholding_tax_table() -> pd.DataFrame:
292    """Return the full withholding tax reference table.
293
294    Returns:
295        DataFrame with columns: tax_category, description, and one column
296        per date period showing the tax rate as a percentage.
297
298    Examples:
299        >>> import borsapy as bp
300        >>> bp.withholding_tax_table()
301           tax_category                  description  <23.12.2020  ...  >=09.07.2025
302        0  degisken_karma_doviz  Degisken, karma, ...        10.0  ...          17.5
303        1  pay_senedi_yogun      Pay senedi yogun fon         0.0  ...           0.0
304        ...
305    """
306    period_labels = [
307        "<23.12.2020",
308        "23.12.2020-30.04.2024",
309        "01.05.2024-31.10.2024",
310        "01.11.2024-31.01.2025",
311        "01.02.2025-08.07.2025",
312        ">=09.07.2025",
313    ]
314
315    rows = []
316    for cat in [
317        TAX_CAT_VARIABLE,
318        TAX_CAT_EQUITY_HEAVY,
319        TAX_CAT_OTHER,
320        TAX_CAT_GSYF_GYF_LONG,
321        TAX_CAT_GSYF_GYF_SHORT,
322    ]:
323        row = {
324            "tax_category": cat,
325            "description": TAX_CAT_DESCRIPTIONS[cat],
326        }
327        for label, rate in zip(period_labels, TAX_RATES[cat], strict=True):
328            row[label] = rate
329        rows.append(row)
330
331    return pd.DataFrame(rows)

Return the full withholding tax reference table.

Returns: DataFrame with columns: tax_category, description, and one column per date period showing the tax rate as a percentage.

Examples:

import borsapy as bp bp.withholding_tax_table() tax_category description <23.12.2020 ... >=09.07.2025 0 degisken_karma_doviz Degisken, karma, ... 10.0 ... 17.5 1 pay_senedi_yogun Pay senedi yogun fon 0.0 ... 0.0 ...

def set_twitter_auth( auth_token: str | None = None, ct0: str | None = None, cookies: dict | None = None, cookies_file: str | None = None) -> None:
20def set_twitter_auth(
21    auth_token: str | None = None,
22    ct0: str | None = None,
23    cookies: dict | None = None,
24    cookies_file: str | None = None,
25) -> None:
26    """
27    Set Twitter/X authentication credentials for tweet search.
28
29    Twitter requires cookie-based authentication. You can get these values
30    from your browser's developer tools after logging into twitter.com/x.com.
31
32    Args:
33        auth_token: The auth_token cookie value from Twitter/X.
34        ct0: The ct0 cookie value from Twitter/X.
35        cookies: Dict with 'auth_token' and 'ct0' keys.
36        cookies_file: Path to a cookies JSON file (Scweet format).
37
38    Examples:
39        >>> import borsapy as bp
40        >>> # Method 1: Direct cookie values
41        >>> bp.set_twitter_auth(auth_token="abc123...", ct0="xyz789...")
42        >>> # Method 2: Dict
43        >>> bp.set_twitter_auth(cookies={"auth_token": "abc123", "ct0": "xyz789"})
44        >>> # Method 3: Cookies file
45        >>> bp.set_twitter_auth(cookies_file="cookies.json")
46    """
47    global _twitter_credentials
48
49    if cookies_file:
50        _twitter_credentials = {"cookies_file": cookies_file}
51    elif cookies:
52        if "auth_token" not in cookies:
53            raise ValueError("cookies dict must contain 'auth_token' key")
54        _twitter_credentials = {"cookies": cookies}
55    elif auth_token:
56        _twitter_credentials = {"cookies": {"auth_token": auth_token, "ct0": ct0 or ""}}
57    else:
58        raise ValueError(
59            "Provide auth_token/ct0, cookies dict, or cookies_file. "
60            "Get auth_token and ct0 from browser DevTools > Application > Cookies > x.com"
61        )

Set Twitter/X authentication credentials for tweet search.

Twitter requires cookie-based authentication. You can get these values from your browser's developer tools after logging into twitter.com/x.com.

Args: auth_token: The auth_token cookie value from Twitter/X. ct0: The ct0 cookie value from Twitter/X. cookies: Dict with 'auth_token' and 'ct0' keys. cookies_file: Path to a cookies JSON file (Scweet format).

Examples:

import borsapy as bp

Method 1: Direct cookie values

bp.set_twitter_auth(auth_token="abc123...", ct0="xyz789...")

Method 2: Dict

bp.set_twitter_auth(cookies={"auth_token": "abc123", "ct0": "xyz789"})

Method 3: Cookies file

bp.set_twitter_auth(cookies_file="cookies.json")

def get_twitter_auth() -> dict | None:
70def get_twitter_auth() -> dict | None:
71    """Get current Twitter/X authentication credentials."""
72    return _twitter_credentials

Get current Twitter/X authentication credentials.

def clear_twitter_auth() -> None:
64def clear_twitter_auth() -> None:
65    """Clear Twitter/X authentication credentials."""
66    global _twitter_credentials
67    _twitter_credentials = None

Clear Twitter/X authentication credentials.

def search_tweets( query: str, period: str | None = '7d', since: str | None = None, until: str | None = None, lang: str | None = None, limit: int = 100) -> pandas.core.frame.DataFrame:
150def search_tweets(
151    query: str,
152    period: str | None = "7d",
153    since: str | None = None,
154    until: str | None = None,
155    lang: str | None = None,
156    limit: int = 100,
157) -> pd.DataFrame:
158    """
159    Search Twitter/X for tweets matching a query.
160
161    Requires authentication: call bp.set_twitter_auth() first.
162    Requires optional dependency: pip install borsapy[twitter]
163
164    Args:
165        query: Search query (e.g., "$THYAO", "dolar kur", "#Bitcoin").
166        period: Time period ("1d", "7d", "1mo"). Ignored if since/until set.
167        since: Start date (YYYY-MM-DD). Overrides period.
168        until: End date (YYYY-MM-DD). Overrides period.
169        lang: Language filter (e.g., "tr", "en").
170        limit: Maximum number of tweets (default 100).
171
172    Returns:
173        DataFrame with columns: tweet_id, created_at, text, author_handle,
174        author_name, likes, retweets, replies, views, quotes, bookmarks,
175        author_followers, author_verified, lang, url.
176
177    Examples:
178        >>> import borsapy as bp
179        >>> bp.set_twitter_auth(auth_token="...", ct0="...")
180        >>> df = bp.search_tweets("$THYAO", period="7d")
181        >>> df = bp.search_tweets("dolar kur", period="1d", lang="tr", limit=50)
182    """
183    provider = get_twitter_provider()
184    return provider.search_tweets(
185        query=query,
186        period=period,
187        since=since,
188        until=until,
189        lang=lang,
190        limit=limit,
191    )

Search Twitter/X for tweets matching a query.

Requires authentication: call bp.set_twitter_auth() first. Requires optional dependency: pip install borsapy[twitter]

Args: query: Search query (e.g., "$THYAO", "dolar kur", "#Bitcoin"). period: Time period ("1d", "7d", "1mo"). Ignored if since/until set. since: Start date (YYYY-MM-DD). Overrides period. until: End date (YYYY-MM-DD). Overrides period. lang: Language filter (e.g., "tr", "en"). limit: Maximum number of tweets (default 100).

Returns: DataFrame with columns: tweet_id, created_at, text, author_handle, author_name, likes, retweets, replies, views, quotes, bookmarks, author_followers, author_verified, lang, url.

Examples:

import borsapy as bp bp.set_twitter_auth(auth_token="...", ct0="...") df = bp.search_tweets("$THYAO", period="7d") df = bp.search_tweets("dolar kur", period="1d", lang="tr", limit=50)