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

Return the ticker symbol.

fast_info: borsapy.ticker.FastInfo
549    @property
550    def fast_info(self) -> FastInfo:
551        """
552        Get fast access to common ticker information.
553
554        Returns a FastInfo object with quick access to frequently used data:
555        - currency, exchange, timezone
556        - last_price, open, day_high, day_low, previous_close, volume
557        - market_cap, shares, pe_ratio, pb_ratio
558        - year_high, year_low (52-week)
559        - fifty_day_average, two_hundred_day_average
560        - free_float, foreign_ratio
561
562        Examples:
563            >>> stock = Ticker("THYAO")
564            >>> stock.fast_info.market_cap
565            370530000000
566            >>> stock.fast_info['pe_ratio']
567            2.8
568            >>> stock.fast_info.keys()
569            ['currency', 'exchange', 'timezone', ...]
570        """
571        if not hasattr(self, "_fast_info"):
572            self._fast_info = FastInfo(self)
573        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
575    @property
576    def info(self) -> EnrichedInfo:
577        """
578        Get comprehensive ticker information with yfinance-compatible fields.
579
580        Returns:
581            EnrichedInfo object providing dict-like access to:
582
583            Basic fields (always loaded, fast):
584            - symbol, last, open, high, low, close, volume
585            - change, change_percent, update_time
586
587            yfinance aliases (map to basic fields):
588            - regularMarketPrice, currentPrice -> last
589            - regularMarketOpen -> open
590            - regularMarketDayHigh -> high
591            - regularMarketDayLow -> low
592            - regularMarketPreviousClose -> close
593            - regularMarketVolume -> volume
594
595            Extended fields (lazy-loaded on access):
596            - marketCap, trailingPE, priceToBook, enterpriseToEbitda
597            - sharesOutstanding, fiftyTwoWeekHigh, fiftyTwoWeekLow
598            - fiftyDayAverage, twoHundredDayAverage
599            - floatShares, foreignRatio, netDebt
600            - currency, exchange, timezone
601
602            Dividend fields (lazy-loaded on access):
603            - dividendYield, exDividendDate
604            - trailingAnnualDividendRate, trailingAnnualDividendYield
605
606        Examples:
607            >>> stock = Ticker("THYAO")
608            >>> stock.info['last']  # Basic field - fast
609            268.5
610            >>> stock.info['marketCap']  # Extended field - fetches İş Yatırım
611            370530000000
612            >>> stock.info['trailingPE']  # yfinance compatible name
613            2.8
614            >>> stock.info.get('dividendYield')  # Safe access
615            1.28
616            >>> stock.info.todict()  # Get all as regular dict
617            {...}
618        """
619        if not hasattr(self, "_enriched_info"):
620            self._enriched_info = EnrichedInfo(self)
621        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) -> pandas.core.frame.DataFrame:
623    def history(
624        self,
625        period: str = "1mo",
626        interval: str = "1d",
627        start: datetime | str | None = None,
628        end: datetime | str | None = None,
629        actions: bool = False,
630        adjust: bool = True,
631    ) -> pd.DataFrame:
632        """
633        Get historical OHLCV data.
634
635        Args:
636            period: How much data to fetch. Valid periods:
637                    1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max.
638                    Ignored if start is provided.
639            interval: Data granularity. Valid intervals:
640                      1m, 5m, 15m, 30m, 1h, 1d, 1wk, 1mo.
641            start: Start date (string or datetime).
642            end: End date (string or datetime). Defaults to today.
643            actions: If True, include Dividends and Stock Splits columns.
644                     Defaults to False.
645            adjust: If True (default), return split-adjusted prices.
646                    If False, return unadjusted (raw) prices.
647
648        Returns:
649            DataFrame with columns: Open, High, Low, Close, Volume.
650            If actions=True, also includes Dividends and Stock Splits columns.
651            Index is the Date.
652
653        Examples:
654            >>> stock = Ticker("THYAO")
655            >>> stock.history(period="1mo")  # Last month
656            >>> stock.history(period="1y", interval="1wk")  # Weekly for 1 year
657            >>> stock.history(start="2024-01-01", end="2024-06-30")  # Date range
658            >>> stock.history(period="1y", actions=True)  # With dividends/splits
659            >>> stock.history(period="max", adjust=False)  # Raw unadjusted prices
660        """
661        # Parse dates if strings
662        start_dt = self._parse_date(start) if start else None
663        end_dt = self._parse_date(end) if end else None
664
665        df = self._tradingview.get_history(
666            symbol=self._symbol,
667            period=period,
668            interval=interval,
669            start=start_dt,
670            end=end_dt,
671        )
672
673        if not adjust and not df.empty:
674            df = self._unadjust_prices(df)
675
676        if actions and not df.empty:
677            df = self._add_actions_to_history(df)
678
679        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.

Returns: DataFrame with columns: Open, High, Low, Close, Volume. If actions=True, also includes Dividends and Stock Splits columns. 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

dividends: pandas.core.frame.DataFrame
788    @cached_property
789    def dividends(self) -> pd.DataFrame:
790        """
791        Get dividend history.
792
793        Returns:
794            DataFrame with dividend history:
795            - Amount: Dividend per share (TL)
796            - GrossRate: Gross dividend rate (%)
797            - NetRate: Net dividend rate (%)
798            - TotalDividend: Total dividend distributed (TL)
799
800        Examples:
801            >>> stock = Ticker("THYAO")
802            >>> stock.dividends
803                           Amount  GrossRate  NetRate  TotalDividend
804            Date
805            2025-09-02     3.442    344.20   292.57  4750000000.0
806            2025-06-16     3.442    344.20   292.57  4750000000.0
807        """
808        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
810    @cached_property
811    def splits(self) -> pd.DataFrame:
812        """
813        Get capital increase (split) history.
814
815        Note: Turkish market uses capital increases instead of traditional splits.
816        - RightsIssue: Paid capital increase (bedelli)
817        - BonusFromCapital: Free shares from capital reserves (bedelsiz iç kaynak)
818        - BonusFromDividend: Free shares from dividend (bedelsiz temettüden)
819
820        Returns:
821            DataFrame with capital increase history:
822            - Capital: New capital after increase (TL)
823            - RightsIssue: Rights issue rate (%)
824            - BonusFromCapital: Bonus from capital (%)
825            - BonusFromDividend: Bonus from dividend (%)
826
827        Examples:
828            >>> stock = Ticker("THYAO")
829            >>> stock.splits
830                             Capital  RightsIssue  BonusFromCapital  BonusFromDividend
831            Date
832            2013-06-26  1380000000.0         0.0             15.00               0.0
833            2011-07-11  1200000000.0         0.0              0.00              20.0
834        """
835        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
837    @cached_property
838    def actions(self) -> pd.DataFrame:
839        """
840        Get combined dividends and splits history.
841
842        Returns:
843            DataFrame with combined dividend and split actions:
844            - Dividends: Dividend per share (TL) or 0
845            - Splits: Combined split ratio (0 if no split)
846
847        Examples:
848            >>> stock = Ticker("THYAO")
849            >>> stock.actions
850                         Dividends  Splits
851            Date
852            2025-09-02      3.442    0.0
853            2013-06-26      0.000   15.0
854        """
855        dividends = self.dividends
856        splits = self.splits
857
858        # Merge on index (Date)
859        if dividends.empty and splits.empty:
860            return pd.DataFrame(columns=["Dividends", "Splits"])
861
862        # Extract relevant columns
863        div_series = dividends["Amount"] if not dividends.empty else pd.Series(dtype=float)
864        split_series = (
865            splits["BonusFromCapital"] + splits["BonusFromDividend"]
866            if not splits.empty
867            else pd.Series(dtype=float)
868        )
869
870        # Combine into single DataFrame
871        result = pd.DataFrame({"Dividends": div_series, "Splits": split_series})
872        result = result.fillna(0)
873        result = result.sort_index(ascending=False)
874
875        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:
877    def get_balance_sheet(
878        self,
879        quarterly: bool = False,
880        financial_group: str | None = None,
881        last_n: int | str | None = None,
882    ) -> pd.DataFrame:
883        """
884        Get balance sheet data.
885
886        Args:
887            quarterly: If True, return quarterly data. If False, return annual.
888            financial_group: Financial group code. Use "UFRS" for banks,
889                           "XI_29" for industrial companies. If None, defaults to XI_29.
890            last_n: Number of periods to fetch. None for default (5), int for exact
891                    count (e.g. 10 = 10 annual periods), "all" for maximum available.
892
893        Returns:
894            DataFrame with balance sheet items as rows and periods as columns.
895
896        Examples:
897            >>> stock = bp.Ticker("THYAO")
898            >>> stock.get_balance_sheet()  # Annual, industrial (5 periods)
899            >>> stock.get_balance_sheet(quarterly=True, last_n=20)  # 20 quarters
900
901            >>> bank = bp.Ticker("AKBNK")
902            >>> bank.get_balance_sheet(financial_group="UFRS", last_n="all")
903        """
904        return self._get_isyatirim().get_financial_statements(
905            symbol=self._symbol,
906            statement_type="balance_sheet",
907            quarterly=quarterly,
908            financial_group=financial_group,
909            last_n=last_n,
910        )

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:
912    def get_income_stmt(
913        self,
914        quarterly: bool = False,
915        financial_group: str | None = None,
916        last_n: int | str | None = None,
917    ) -> pd.DataFrame:
918        """
919        Get income statement data.
920
921        Args:
922            quarterly: If True, return quarterly data. If False, return annual.
923            financial_group: Financial group code. Use "UFRS" for banks,
924                           "XI_29" for industrial companies. If None, defaults to XI_29.
925            last_n: Number of periods to fetch. None for default (5), int for exact
926                    count (e.g. 10 = 10 annual periods), "all" for maximum available.
927
928        Returns:
929            DataFrame with income statement items as rows and periods as columns.
930
931        Examples:
932            >>> stock = bp.Ticker("THYAO")
933            >>> stock.get_income_stmt()  # Annual (5 periods)
934            >>> stock.get_income_stmt(quarterly=True, last_n=20)  # 20 quarters
935
936            >>> bank = bp.Ticker("AKBNK")
937            >>> bank.get_income_stmt(quarterly=True, financial_group="UFRS")
938        """
939        return self._get_isyatirim().get_financial_statements(
940            symbol=self._symbol,
941            statement_type="income_stmt",
942            quarterly=quarterly,
943            financial_group=financial_group,
944            last_n=last_n,
945        )

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

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
983    @cached_property
984    def balance_sheet(self) -> pd.DataFrame:
985        """Annual balance sheet (use get_balance_sheet() for more options)."""
986        return self.get_balance_sheet(quarterly=False)

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

quarterly_balance_sheet: pandas.core.frame.DataFrame
988    @cached_property
989    def quarterly_balance_sheet(self) -> pd.DataFrame:
990        """Quarterly balance sheet (use get_balance_sheet(quarterly=True) for more options)."""
991        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
993    @cached_property
994    def income_stmt(self) -> pd.DataFrame:
995        """Annual income statement (use get_income_stmt() for more options)."""
996        return self.get_income_stmt(quarterly=False)

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

quarterly_income_stmt: pandas.core.frame.DataFrame
 998    @cached_property
 999    def quarterly_income_stmt(self) -> pd.DataFrame:
1000        """Quarterly income statement (use get_income_stmt(quarterly=True) for more options)."""
1001        return self.get_income_stmt(quarterly=True)

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

cashflow: pandas.core.frame.DataFrame
1003    @cached_property
1004    def cashflow(self) -> pd.DataFrame:
1005        """Annual cash flow (use get_cashflow() for more options)."""
1006        return self.get_cashflow(quarterly=False)

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

quarterly_cashflow: pandas.core.frame.DataFrame
1008    @cached_property
1009    def quarterly_cashflow(self) -> pd.DataFrame:
1010        """Quarterly cash flow (use get_cashflow(quarterly=True) for more options)."""
1011        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:
1034    def get_ttm_income_stmt(self, financial_group: str | None = None) -> pd.DataFrame:
1035        """
1036        Get trailing twelve months (TTM) income statement.
1037
1038        Calculates TTM by summing the last 4 quarters of income statement data.
1039
1040        Args:
1041            financial_group: Financial group code. Use "UFRS" for banks,
1042                           "XI_29" for industrial companies. If None, defaults to XI_29.
1043
1044        Returns:
1045            DataFrame with TTM column containing summed values for each line item.
1046
1047        Examples:
1048            >>> stock = bp.Ticker("THYAO")
1049            >>> stock.get_ttm_income_stmt()
1050
1051            >>> bank = bp.Ticker("AKBNK")
1052            >>> bank.get_ttm_income_stmt(financial_group="UFRS")
1053        """
1054        quarterly = self.get_income_stmt(quarterly=True, financial_group=financial_group)
1055        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:
1057    def get_ttm_cashflow(self, financial_group: str | None = None) -> pd.DataFrame:
1058        """
1059        Get trailing twelve months (TTM) cash flow statement.
1060
1061        Calculates TTM by summing the last 4 quarters of cash flow data.
1062
1063        Args:
1064            financial_group: Financial group code. Use "UFRS" for banks,
1065                           "XI_29" for industrial companies. If None, defaults to XI_29.
1066
1067        Returns:
1068            DataFrame with TTM column containing summed values for each line item.
1069
1070        Examples:
1071            >>> stock = bp.Ticker("THYAO")
1072            >>> stock.get_ttm_cashflow()
1073
1074            >>> bank = bp.Ticker("AKBNK")
1075            >>> bank.get_ttm_cashflow(financial_group="UFRS")
1076        """
1077        quarterly = self.get_cashflow(quarterly=True, financial_group=financial_group)
1078        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
1081    @cached_property
1082    def ttm_income_stmt(self) -> pd.DataFrame:
1083        """TTM income statement (use get_ttm_income_stmt() for banks)."""
1084        return self.get_ttm_income_stmt()

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

ttm_cashflow: pandas.core.frame.DataFrame
1086    @cached_property
1087    def ttm_cashflow(self) -> pd.DataFrame:
1088        """TTM cash flow (use get_ttm_cashflow() for banks)."""
1089        return self.get_ttm_cashflow()

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

major_holders: pandas.core.frame.DataFrame
1091    @cached_property
1092    def major_holders(self) -> pd.DataFrame:
1093        """
1094        Get major shareholders (ortaklık yapısı).
1095
1096        Returns:
1097            DataFrame with shareholder names and percentages:
1098            - Index: Holder name
1099            - Percentage: Ownership percentage (%)
1100
1101        Examples:
1102            >>> stock = Ticker("THYAO")
1103            >>> stock.major_holders
1104                                     Percentage
1105            Holder
1106            DiÄŸer                        50.88
1107            Türkiye Varlık Fonu          49.12
1108        """
1109        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
1111    @cached_property
1112    def recommendations(self) -> dict:
1113        """
1114        Get analyst recommendations and target price.
1115
1116        Returns:
1117            Dictionary with:
1118            - recommendation: Buy/Hold/Sell (AL/TUT/SAT)
1119            - target_price: Analyst target price (TL)
1120            - upside_potential: Expected upside (%)
1121
1122        Examples:
1123            >>> stock = Ticker("THYAO")
1124            >>> stock.recommendations
1125            {'recommendation': 'AL', 'target_price': 579.99, 'upside_potential': 116.01}
1126        """
1127        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]
1129    @cached_property
1130    def recommendations_summary(self) -> dict[str, int]:
1131        """
1132        Get analyst recommendation summary with buy/hold/sell counts.
1133
1134        Aggregates individual analyst recommendations from hedeffiyat.com.tr
1135        into yfinance-compatible categories.
1136
1137        Returns:
1138            Dictionary with counts:
1139            - strongBuy: Strong buy recommendations
1140            - buy: Buy recommendations (includes "Endeks Üstü Getiri")
1141            - hold: Hold recommendations (includes "Nötr", "Endekse Paralel")
1142            - sell: Sell recommendations (includes "Endeks Altı Getiri")
1143            - strongSell: Strong sell recommendations
1144
1145        Examples:
1146            >>> stock = Ticker("THYAO")
1147            >>> stock.recommendations_summary
1148            {'strongBuy': 0, 'buy': 31, 'hold': 0, 'sell': 0, 'strongSell': 0}
1149        """
1150        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
1152    @cached_property
1153    def news(self) -> pd.DataFrame:
1154        """
1155        Get recent KAP (Kamuyu Aydınlatma Platformu) disclosures for the stock.
1156
1157        Fetches directly from KAP - the official disclosure platform for
1158        publicly traded companies in Turkey.
1159
1160        Returns:
1161            DataFrame with columns:
1162            - Date: Disclosure date and time
1163            - Title: Disclosure headline
1164            - URL: Link to full disclosure on KAP
1165
1166        Examples:
1167            >>> stock = Ticker("THYAO")
1168            >>> stock.news
1169                              Date                                         Title                                         URL
1170            0  29.12.2025 19:21:18  Haber ve Söylentilere İlişkin Açıklama  https://www.kap.org.tr/tr/Bildirim/1530826
1171            1  29.12.2025 16:11:36  Payların Geri Alınmasına İlişkin Bildirim  https://www.kap.org.tr/tr/Bildirim/1530656
1172        """
1173        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:
1175    def get_news_content(self, disclosure_id: int | str) -> str | None:
1176        """
1177        Get full HTML content of a KAP disclosure by ID.
1178
1179        Args:
1180            disclosure_id: KAP disclosure ID from news DataFrame URL.
1181
1182        Returns:
1183            Raw HTML content or None if failed.
1184
1185        Examples:
1186            >>> stock = Ticker("THYAO")
1187            >>> html = stock.get_news_content(1530826)
1188        """
1189        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
1191    @cached_property
1192    def calendar(self) -> pd.DataFrame:
1193        """
1194        Get expected disclosure calendar for the stock from KAP.
1195
1196        Returns upcoming expected disclosures like financial reports,
1197        annual reports, sustainability reports, and corporate governance reports.
1198
1199        Returns:
1200            DataFrame with columns:
1201            - StartDate: Expected disclosure window start
1202            - EndDate: Expected disclosure window end
1203            - Subject: Type of disclosure (e.g., "Finansal Rapor")
1204            - Period: Report period (e.g., "Yıllık", "3 Aylık")
1205            - Year: Fiscal year
1206
1207        Examples:
1208            >>> stock = Ticker("THYAO")
1209            >>> stock.calendar
1210                  StartDate       EndDate               Subject   Period  Year
1211            0  01.01.2026  11.03.2026       Finansal Rapor   Yıllık  2025
1212            1  01.01.2026  11.03.2026    Faaliyet Raporu  Yıllık  2025
1213            2  01.04.2026  11.05.2026       Finansal Rapor  3 Aylık  2026
1214        """
1215        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
1217    @cached_property
1218    def isin(self) -> str | None:
1219        """
1220        Get ISIN (International Securities Identification Number) code.
1221
1222        ISIN is a 12-character alphanumeric code that uniquely identifies
1223        a security, standardized by ISO 6166.
1224
1225        Returns:
1226            ISIN code string (e.g., "TRATHYAO91M5") or None if not found.
1227
1228        Examples:
1229            >>> stock = Ticker("THYAO")
1230            >>> stock.isin
1231            'TRATHYAO91M5'
1232        """
1233        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]
1235    @cached_property
1236    def analyst_price_targets(self) -> dict[str, float | int | None]:
1237        """
1238        Get analyst price target data from hedeffiyat.com.tr.
1239
1240        Returns aggregated price target information from multiple analysts.
1241
1242        Returns:
1243            Dictionary with:
1244            - current: Current stock price
1245            - low: Lowest analyst target price
1246            - high: Highest analyst target price
1247            - mean: Average target price
1248            - median: Median target price
1249            - numberOfAnalysts: Number of analysts covering the stock
1250
1251        Examples:
1252            >>> stock = Ticker("THYAO")
1253            >>> stock.analyst_price_targets
1254            {'current': 268.5, 'low': 388.0, 'high': 580.0, 'mean': 474.49,
1255             'median': 465.0, 'numberOfAnalysts': 19}
1256        """
1257        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
1259    @property
1260    def etf_holders(self) -> pd.DataFrame:
1261        """
1262        Get international ETFs that hold this stock.
1263
1264        Returns data from TradingView showing which ETFs hold this stock,
1265        including position value, weight, and ETF characteristics.
1266
1267        Returns:
1268            DataFrame with ETF holder information:
1269            - symbol: ETF ticker symbol
1270            - exchange: Exchange (AMEX, NASDAQ, LSE, etc.)
1271            - name: ETF full name
1272            - market_cap_usd: Position value in USD
1273            - holding_weight_pct: Weight percentage (0.09 = 0.09%)
1274            - issuer: ETF issuer (BlackRock, Vanguard, etc.)
1275            - management: Management style (Passive/Active)
1276            - focus: Investment focus (Total Market, Emerging Markets, etc.)
1277            - expense_ratio: Expense ratio (0.09 = 0.09%)
1278            - aum_usd: Total assets under management (USD)
1279            - price: Current ETF price
1280            - change_pct: Change percentage
1281
1282        Examples:
1283            >>> stock = Ticker("ASELS")
1284            >>> holders = stock.etf_holders
1285            >>> holders[['symbol', 'name', 'holding_weight_pct']].head()
1286               symbol                                      name  holding_weight_pct
1287            0    IEMG  iShares Core MSCI Emerging Markets ETF            0.090686
1288            1     VWO     Vanguard FTSE Emerging Markets ETF            0.060000
1289
1290            >>> print(f"Total ETFs: {len(holders)}")
1291            Total ETFs: 118
1292        """
1293        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
1295    @cached_property
1296    def earnings_dates(self) -> pd.DataFrame:
1297        """
1298        Get upcoming earnings announcement dates.
1299
1300        Derived from KAP calendar, showing expected financial report dates.
1301        Compatible with yfinance earnings_dates format.
1302
1303        Returns:
1304            DataFrame with index as Earnings Date and columns:
1305            - EPS Estimate: Always None (not available for BIST)
1306            - Reported EPS: Always None (not available for BIST)
1307            - Surprise (%): Always None (not available for BIST)
1308
1309        Examples:
1310            >>> stock = Ticker("THYAO")
1311            >>> stock.earnings_dates
1312                            EPS Estimate  Reported EPS  Surprise(%)
1313            Earnings Date
1314            2026-03-11           None          None         None
1315            2026-05-11           None          None         None
1316        """
1317        cal = self.calendar
1318        if cal.empty:
1319            return pd.DataFrame(
1320                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1321            )
1322
1323        # Filter for financial reports only
1324        financial_reports = cal[
1325            cal["Subject"].str.contains("Finansal Rapor", case=False, na=False)
1326        ]
1327
1328        if financial_reports.empty:
1329            return pd.DataFrame(
1330                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1331            )
1332
1333        # Use EndDate as the earnings date (latest expected date)
1334        earnings_dates = []
1335        for _, row in financial_reports.iterrows():
1336            end_date = row.get("EndDate", "")
1337            if end_date:
1338                try:
1339                    # Parse Turkish date format (DD.MM.YYYY)
1340                    parsed = datetime.strptime(end_date, "%d.%m.%Y")
1341                    earnings_dates.append(parsed)
1342                except ValueError:
1343                    continue
1344
1345        if not earnings_dates:
1346            return pd.DataFrame(
1347                columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]
1348            )
1349
1350        result = pd.DataFrame(
1351            {
1352                "EPS Estimate": [None] * len(earnings_dates),
1353                "Reported EPS": [None] * len(earnings_dates),
1354                "Surprise(%)": [None] * len(earnings_dates),
1355            },
1356            index=pd.DatetimeIndex(earnings_dates, name="Earnings Date"),
1357        )
1358        result = result.sort_index()
1359        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 type by trying history API with different fund types."""
 91        if self._fund_type or self._detected_fund_type:
 92            return
 93
 94        from datetime import timedelta
 95
 96        end_dt = datetime.now()
 97        start_dt = end_dt - timedelta(days=7)
 98
 99        # Try YAT first
100        try:
101            df = self._provider._fetch_history_chunk(
102                self._fund_code, start_dt, end_dt, fund_type="YAT"
103            )
104            if not df.empty:
105                self._detected_fund_type = "YAT"
106                return
107        except DataNotAvailableError:
108            pass
109
110        # Try EMK
111        try:
112            df = self._provider._fetch_history_chunk(
113                self._fund_code, start_dt, end_dt, fund_type="EMK"
114            )
115            if not df.empty:
116                self._detected_fund_type = "EMK"
117                return
118        except DataNotAvailableError:
119            pass
120
121        # Default to YAT if neither works
122        self._detected_fund_type = "YAT"
123
124    @property
125    def info(self) -> dict[str, Any]:
126        """
127        Get detailed fund information.
128
129        Returns:
130            Dictionary with fund details:
131            - fund_code: TEFAS fund code
132            - name: Fund full name
133            - date: Last update date
134            - price: Current unit price
135            - fund_size: Total fund size (TRY)
136            - investor_count: Number of investors
137            - founder: Fund founder company
138            - manager: Fund manager company
139            - fund_type: Fund type
140            - category: Fund category
141            - risk_value: Risk rating (1-7)
142            - return_1m, return_3m, return_6m: Period returns
143            - return_ytd: Year-to-date return
144            - return_1y, return_3y, return_5y: Annual returns
145            - daily_return: Daily return
146        """
147        if self._info_cache is None:
148            # GetAllFundAnalyzeData works for both YAT and EMK without fontip
149            self._info_cache = self._provider.get_fund_detail(self._fund_code)
150
151            # If fund_type not explicitly set, we need to detect it for history/allocation
152            if not self._fund_type and not self._detected_fund_type:
153                # Detection will happen on first history() call
154                pass
155
156        return self._info_cache
157
158    @property
159    def detail(self) -> dict[str, Any]:
160        """Alias for info property."""
161        return self.info
162
163    @property
164    def performance(self) -> dict[str, Any]:
165        """
166        Get fund performance metrics only.
167
168        Returns:
169            Dictionary with performance data:
170            - daily_return: Daily return
171            - return_1m, return_3m, return_6m: Period returns
172            - return_ytd: Year-to-date return
173            - return_1y, return_3y, return_5y: Annual returns
174        """
175        info = self.info
176        return {
177            "daily_return": info.get("daily_return"),
178            "return_1m": info.get("return_1m"),
179            "return_3m": info.get("return_3m"),
180            "return_6m": info.get("return_6m"),
181            "return_ytd": info.get("return_ytd"),
182            "return_1y": info.get("return_1y"),
183            "return_3y": info.get("return_3y"),
184            "return_5y": info.get("return_5y"),
185        }
186
187    @property
188    def management_fee(self) -> dict[str, Any]:
189        """
190        Get management fee information for this fund.
191
192        Returns:
193            Dictionary with keys:
194            - applied_fee: Applied annual management fee (%)
195            - prospectus_fee: Prospectus management fee (%)
196            - max_expense_ratio: Maximum total expense ratio (%)
197            - annual_return: Annual return (%)
198
199        Examples:
200            >>> fund = bp.Fund("AAK")
201            >>> fund.management_fee
202            {'applied_fee': 1.0, 'prospectus_fee': 2.2, 'max_expense_ratio': 3.65, 'annual_return': 45.5}
203        """
204        empty = {
205            "applied_fee": None,
206            "prospectus_fee": None,
207            "max_expense_ratio": None,
208            "annual_return": None,
209        }
210
211        try:
212            fees_list = self._provider.get_management_fees(fund_type=self.fund_type)
213        except Exception:
214            return empty
215
216        for item in fees_list:
217            if item.get("fund_code") == self._fund_code:
218                return {
219                    "applied_fee": item.get("applied_fee"),
220                    "prospectus_fee": item.get("prospectus_fee"),
221                    "max_expense_ratio": item.get("max_expense_ratio"),
222                    "annual_return": item.get("annual_return"),
223                }
224
225        return empty
226
227    @property
228    def tax_category(self) -> str | None:
229        """
230        Get the tax category for this fund based on its TEFAS category.
231
232        Returns:
233            Tax category identifier string (e.g., "degisken_karma_doviz",
234            "pay_senedi_yogun"), or None if the category cannot be determined.
235
236        Examples:
237            >>> fund = bp.Fund("AAK")
238            >>> fund.tax_category
239            'borclanma_para_maden'
240        """
241        from borsapy.tax import classify_fund_tax_category
242
243        info = self.info
244        category = info.get("category", "") or ""
245        fund_name = info.get("name", "") or ""
246        return classify_fund_tax_category(category, fund_name)
247
248    def withholding_tax_rate(
249        self,
250        purchase_date: datetime | str | None = None,
251        holding_days: int | None = None,
252    ) -> float | None:
253        """
254        Get the withholding tax (stopaj) rate for this fund.
255
256        Args:
257            purchase_date: Date of fund purchase. Accepts datetime, date, or
258                          "YYYY-MM-DD" string. Defaults to today.
259            holding_days: Number of days held. Relevant for GSYF/GYF funds
260                         where >730 days qualifies for 0% rate.
261
262        Returns:
263            Tax rate as a decimal (e.g., 0.15 for 15%), or None if the
264            fund category cannot be determined.
265
266        Examples:
267            >>> fund = bp.Fund("AAK")
268            >>> fund.withholding_tax_rate("2025-06-01")
269            0.15
270            >>> fund.withholding_tax_rate("2025-08-01")
271            0.175
272        """
273        from datetime import date
274
275        from borsapy.tax import get_withholding_tax_rate
276
277        cat = self.tax_category
278        if cat is None:
279            return None
280        if purchase_date is None:
281            purchase_date = date.today()
282        elif isinstance(purchase_date, datetime):
283            purchase_date = purchase_date.date()
284        return get_withholding_tax_rate(cat, purchase_date, holding_days)
285
286    @property
287    def allocation(self) -> pd.DataFrame:
288        """
289        Get current portfolio allocation (asset breakdown) for last 7 days.
290
291        For longer periods, use allocation_history() method.
292
293        Returns:
294            DataFrame with columns: Date, asset_type, asset_name, weight.
295
296        Examples:
297            >>> fund = Fund("AAK")
298            >>> fund.allocation
299                             Date asset_type         asset_name  weight
300            0 2024-12-20         HS        Hisse Senedi   45.32
301            1 2024-12-20         DB        Devlet Bonusu  30.15
302            ...
303        """
304        return self._provider.get_allocation(self._fund_code, fund_type=self.fund_type)
305
306    def allocation_history(
307        self,
308        period: str = "1mo",
309        start: datetime | str | None = None,
310        end: datetime | str | None = None,
311    ) -> pd.DataFrame:
312        """
313        Get historical portfolio allocation (asset breakdown).
314
315        Note: TEFAS API supports maximum ~100 days (3 months) of data.
316
317        Args:
318            period: How much data to fetch. Valid periods:
319                    1d, 5d, 1mo, 3mo (max ~100 days).
320                    Ignored if start is provided.
321            start: Start date (string or datetime).
322            end: End date (string or datetime). Defaults to today.
323
324        Returns:
325            DataFrame with columns: Date, asset_type, asset_name, weight.
326
327        Examples:
328            >>> fund = Fund("AAK")
329            >>> fund.allocation_history(period="1mo")  # Last month
330            >>> fund.allocation_history(period="3mo")  # Last 3 months (max)
331            >>> fund.allocation_history(start="2024-10-01", end="2024-12-31")
332        """
333        start_dt = self._parse_date(start) if start else None
334        end_dt = self._parse_date(end) if end else None
335
336        # If no start date, calculate from period
337        if start_dt is None:
338            from datetime import timedelta
339            end_dt = end_dt or datetime.now()
340            days = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90}.get(period, 30)
341            # Cap at 100 days (API limit)
342            days = min(days, 100)
343            start_dt = end_dt - timedelta(days=days)
344
345        return self._provider.get_allocation(
346            fund_code=self._fund_code,
347            start=start_dt,
348            end=end_dt,
349            fund_type=self.fund_type,
350        )
351
352    def history(
353        self,
354        period: str = "1mo",
355        start: datetime | str | None = None,
356        end: datetime | str | None = None,
357    ) -> pd.DataFrame:
358        """
359        Get historical price data.
360
361        Args:
362            period: How much data to fetch. Valid periods:
363                    1d, 5d, 1mo, 3mo, 6mo, 1y.
364                    Ignored if start is provided.
365            start: Start date (string or datetime).
366            end: End date (string or datetime). Defaults to now.
367
368        Returns:
369            DataFrame with columns: Price, FundSize, Investors.
370            Index is the Date.
371
372        Examples:
373            >>> fund = Fund("AAK")
374            >>> fund.history(period="1mo")  # Last month
375            >>> fund.history(period="1y")  # Last year
376            >>> fund.history(start="2024-01-01", end="2024-06-30")  # Date range
377        """
378        start_dt = self._parse_date(start) if start else None
379        end_dt = self._parse_date(end) if end else None
380
381        return self._provider.get_history(
382            fund_code=self._fund_code,
383            period=period,
384            start=start_dt,
385            end=end_dt,
386            fund_type=self.fund_type,
387        )
388
389    def _parse_date(self, date: str | datetime) -> datetime:
390        """Parse a date string to datetime."""
391        if isinstance(date, datetime):
392            return date
393        for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"]:
394            try:
395                return datetime.strptime(date, fmt)
396            except ValueError:
397                continue
398        raise ValueError(f"Could not parse date: {date}")
399
400    def sharpe_ratio(self, period: str = "1y", risk_free_rate: float | None = None) -> float:
401        """
402        Calculate the Sharpe ratio for the fund.
403
404        Sharpe Ratio = (Rp - Rf) / σp
405        Where:
406        - Rp = Annualized return of the fund
407        - Rf = Risk-free rate (default: 10Y government bond yield)
408        - σp = Annualized standard deviation of returns
409
410        Args:
411            period: Period for calculation ("1y", "3y", "5y"). Default is "1y".
412            risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
413                           If None, uses current 10Y bond yield from bp.risk_free_rate().
414
415        Returns:
416            Sharpe ratio as float. Higher is better (>1 good, >2 very good, >3 excellent).
417
418        Examples:
419            >>> fund = bp.Fund("YAY")
420            >>> fund.sharpe_ratio()  # 1-year Sharpe with current risk-free rate
421            0.85
422
423            >>> fund.sharpe_ratio(period="3y")  # 3-year Sharpe
424            1.23
425
426            >>> fund.sharpe_ratio(risk_free_rate=0.25)  # Custom risk-free rate
427            0.92
428        """
429        metrics = self.risk_metrics(period=period, risk_free_rate=risk_free_rate)
430        return metrics.get("sharpe_ratio", np.nan)
431
432    def risk_metrics(
433        self,
434        period: str = "1y",
435        risk_free_rate: float | None = None,
436    ) -> dict[str, Any]:
437        """
438        Calculate comprehensive risk metrics for the fund.
439
440        Args:
441            period: Period for calculation ("1y", "3y", "5y"). Default is "1y".
442            risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
443                           If None, uses current 10Y bond yield.
444
445        Returns:
446            Dictionary with risk metrics:
447            - annualized_return: Annualized return (%)
448            - annualized_volatility: Annualized standard deviation (%)
449            - sharpe_ratio: Risk-adjusted return (Rp - Rf) / σp
450            - sortino_ratio: Downside risk-adjusted return
451            - max_drawdown: Maximum peak-to-trough decline (%)
452            - risk_free_rate: Risk-free rate used (%)
453            - trading_days: Number of trading days in the period
454
455        Examples:
456            >>> fund = bp.Fund("YAY")
457            >>> metrics = fund.risk_metrics()
458            >>> print(f"Sharpe: {metrics['sharpe_ratio']:.2f}")
459            >>> print(f"Max Drawdown: {metrics['max_drawdown']:.1f}%")
460        """
461        # Get historical data
462        df = self.history(period=period)
463
464        if df.empty or len(df) < 20:
465            return {
466                "annualized_return": np.nan,
467                "annualized_volatility": np.nan,
468                "sharpe_ratio": np.nan,
469                "sortino_ratio": np.nan,
470                "max_drawdown": np.nan,
471                "risk_free_rate": np.nan,
472                "trading_days": 0,
473            }
474
475        # Calculate daily returns
476        prices = df["Price"]
477        daily_returns = prices.pct_change().dropna()
478        trading_days = len(daily_returns)
479
480        # Annualization factor (trading days per year)
481        annualization_factor = 252
482
483        # Annualized return
484        total_return = (prices.iloc[-1] / prices.iloc[0]) - 1
485        years = trading_days / annualization_factor
486        annualized_return = ((1 + total_return) ** (1 / years) - 1) * 100
487
488        # Annualized volatility
489        daily_volatility = daily_returns.std()
490        annualized_volatility = daily_volatility * np.sqrt(annualization_factor) * 100
491
492        # Get risk-free rate
493        if risk_free_rate is None:
494            try:
495                from borsapy.bond import risk_free_rate as get_rf_rate
496                rf = get_rf_rate() * 100  # Returns decimal like 0.28, convert to %
497            except Exception:
498                rf = 30.0  # Fallback: approximate Turkish 10Y yield
499        else:
500            rf = risk_free_rate * 100  # Convert decimal to percentage
501
502        # Sharpe Ratio
503        if annualized_volatility > 0:
504            sharpe = (annualized_return - rf) / annualized_volatility
505        else:
506            sharpe = np.nan
507
508        # Sortino Ratio (uses downside deviation)
509        negative_returns = daily_returns[daily_returns < 0]
510        if len(negative_returns) > 0:
511            downside_deviation = negative_returns.std() * np.sqrt(annualization_factor) * 100
512            if downside_deviation > 0:
513                sortino = (annualized_return - rf) / downside_deviation
514            else:
515                sortino = np.nan
516        else:
517            sortino = np.inf  # No negative returns
518
519        # Maximum Drawdown
520        cumulative = (1 + daily_returns).cumprod()
521        running_max = cumulative.cummax()
522        drawdowns = (cumulative - running_max) / running_max
523        max_drawdown = drawdowns.min() * 100  # Negative percentage
524
525        return {
526            "annualized_return": round(annualized_return, 2),
527            "annualized_volatility": round(annualized_volatility, 2),
528            "sharpe_ratio": round(sharpe, 2) if not np.isnan(sharpe) else np.nan,
529            "sortino_ratio": round(sortino, 2) if not np.isnan(sortino) and not np.isinf(sortino) else sortino,
530            "max_drawdown": round(max_drawdown, 2),
531            "risk_free_rate": round(rf, 2),
532            "trading_days": trading_days,
533        }
534
535    def get_holdings(
536        self,
537        api_key: str,
538        period: str | None = None,
539    ) -> pd.DataFrame:
540        """
541        Get detailed portfolio holdings (individual securities).
542
543        Returns the specific stocks, ETFs, and funds held by this fund,
544        with their weights and ISIN codes. Data is sourced from KAP
545        "Portföy Dağılım Raporu" (Portfolio Distribution Report) disclosures.
546
547        Uses OpenRouter LLM for PDF parsing.
548
549        Args:
550            api_key: OpenRouter API key for LLM parsing.
551                    Get your free API key at: https://openrouter.ai/
552            period: Optional period in format "YYYY-MM" (e.g., "2025-12").
553                   If None, returns the most recent holdings.
554
555        Returns:
556            DataFrame with columns:
557            - symbol: Security symbol (e.g., "GOOGL", "THYAO")
558            - isin: ISIN code
559            - name: Full security name
560            - weight: Portfolio weight (%)
561            - type: Holding type ('stock', 'etf', 'fund', 'viop', etc.)
562            - country: Country ('TR', 'US', or None)
563            - value: Market value in TRY
564
565        Raises:
566            DataNotAvailableError: If holdings data not available.
567            APIError: If LLM parsing fails.
568            ImportError: If required packages are not installed.
569
570        Examples:
571            >>> fund = bp.Fund("YAY")
572            >>> fund.get_holdings(api_key="sk-or-v1-...")
573               symbol              isin                              name  weight   type country         value
574            0   GOOGL  US02079K3059             ALPHABET INC CL A    6.76  stock      US  82478088.0
575            1    AVGO  US11135F1012             BROADCOM INC          5.11  stock      US  62345678.0
576            ...
577
578            >>> # Get holdings for specific period
579            >>> fund.get_holdings(api_key="sk-or-v1-...", period="2025-12")
580
581            >>> # Filter by type
582            >>> holdings = fund.get_holdings(api_key="sk-or-v1-...")
583            >>> holdings[holdings['type'] == 'stock']
584        """
585        from borsapy._providers.kap_holdings import get_kap_holdings_provider
586
587        provider = get_kap_holdings_provider()
588        return provider.get_holdings_df(self._fund_code, api_key, period=period)
589
590    def __repr__(self) -> str:
591        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]
124    @property
125    def info(self) -> dict[str, Any]:
126        """
127        Get detailed fund information.
128
129        Returns:
130            Dictionary with fund details:
131            - fund_code: TEFAS fund code
132            - name: Fund full name
133            - date: Last update date
134            - price: Current unit price
135            - fund_size: Total fund size (TRY)
136            - investor_count: Number of investors
137            - founder: Fund founder company
138            - manager: Fund manager company
139            - fund_type: Fund type
140            - category: Fund category
141            - risk_value: Risk rating (1-7)
142            - return_1m, return_3m, return_6m: Period returns
143            - return_ytd: Year-to-date return
144            - return_1y, return_3y, return_5y: Annual returns
145            - daily_return: Daily return
146        """
147        if self._info_cache is None:
148            # GetAllFundAnalyzeData works for both YAT and EMK without fontip
149            self._info_cache = self._provider.get_fund_detail(self._fund_code)
150
151            # If fund_type not explicitly set, we need to detect it for history/allocation
152            if not self._fund_type and not self._detected_fund_type:
153                # Detection will happen on first history() call
154                pass
155
156        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]
158    @property
159    def detail(self) -> dict[str, Any]:
160        """Alias for info property."""
161        return self.info

Alias for info property.

performance: dict[str, typing.Any]
163    @property
164    def performance(self) -> dict[str, Any]:
165        """
166        Get fund performance metrics only.
167
168        Returns:
169            Dictionary with performance data:
170            - daily_return: Daily return
171            - return_1m, return_3m, return_6m: Period returns
172            - return_ytd: Year-to-date return
173            - return_1y, return_3y, return_5y: Annual returns
174        """
175        info = self.info
176        return {
177            "daily_return": info.get("daily_return"),
178            "return_1m": info.get("return_1m"),
179            "return_3m": info.get("return_3m"),
180            "return_6m": info.get("return_6m"),
181            "return_ytd": info.get("return_ytd"),
182            "return_1y": info.get("return_1y"),
183            "return_3y": info.get("return_3y"),
184            "return_5y": info.get("return_5y"),
185        }

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]
187    @property
188    def management_fee(self) -> dict[str, Any]:
189        """
190        Get management fee information for this fund.
191
192        Returns:
193            Dictionary with keys:
194            - applied_fee: Applied annual management fee (%)
195            - prospectus_fee: Prospectus management fee (%)
196            - max_expense_ratio: Maximum total expense ratio (%)
197            - annual_return: Annual return (%)
198
199        Examples:
200            >>> fund = bp.Fund("AAK")
201            >>> fund.management_fee
202            {'applied_fee': 1.0, 'prospectus_fee': 2.2, 'max_expense_ratio': 3.65, 'annual_return': 45.5}
203        """
204        empty = {
205            "applied_fee": None,
206            "prospectus_fee": None,
207            "max_expense_ratio": None,
208            "annual_return": None,
209        }
210
211        try:
212            fees_list = self._provider.get_management_fees(fund_type=self.fund_type)
213        except Exception:
214            return empty
215
216        for item in fees_list:
217            if item.get("fund_code") == self._fund_code:
218                return {
219                    "applied_fee": item.get("applied_fee"),
220                    "prospectus_fee": item.get("prospectus_fee"),
221                    "max_expense_ratio": item.get("max_expense_ratio"),
222                    "annual_return": item.get("annual_return"),
223                }
224
225        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
227    @property
228    def tax_category(self) -> str | None:
229        """
230        Get the tax category for this fund based on its TEFAS category.
231
232        Returns:
233            Tax category identifier string (e.g., "degisken_karma_doviz",
234            "pay_senedi_yogun"), or None if the category cannot be determined.
235
236        Examples:
237            >>> fund = bp.Fund("AAK")
238            >>> fund.tax_category
239            'borclanma_para_maden'
240        """
241        from borsapy.tax import classify_fund_tax_category
242
243        info = self.info
244        category = info.get("category", "") or ""
245        fund_name = info.get("name", "") or ""
246        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:
248    def withholding_tax_rate(
249        self,
250        purchase_date: datetime | str | None = None,
251        holding_days: int | None = None,
252    ) -> float | None:
253        """
254        Get the withholding tax (stopaj) rate for this fund.
255
256        Args:
257            purchase_date: Date of fund purchase. Accepts datetime, date, or
258                          "YYYY-MM-DD" string. Defaults to today.
259            holding_days: Number of days held. Relevant for GSYF/GYF funds
260                         where >730 days qualifies for 0% rate.
261
262        Returns:
263            Tax rate as a decimal (e.g., 0.15 for 15%), or None if the
264            fund category cannot be determined.
265
266        Examples:
267            >>> fund = bp.Fund("AAK")
268            >>> fund.withholding_tax_rate("2025-06-01")
269            0.15
270            >>> fund.withholding_tax_rate("2025-08-01")
271            0.175
272        """
273        from datetime import date
274
275        from borsapy.tax import get_withholding_tax_rate
276
277        cat = self.tax_category
278        if cat is None:
279            return None
280        if purchase_date is None:
281            purchase_date = date.today()
282        elif isinstance(purchase_date, datetime):
283            purchase_date = purchase_date.date()
284        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
286    @property
287    def allocation(self) -> pd.DataFrame:
288        """
289        Get current portfolio allocation (asset breakdown) for last 7 days.
290
291        For longer periods, use allocation_history() method.
292
293        Returns:
294            DataFrame with columns: Date, asset_type, asset_name, weight.
295
296        Examples:
297            >>> fund = Fund("AAK")
298            >>> fund.allocation
299                             Date asset_type         asset_name  weight
300            0 2024-12-20         HS        Hisse Senedi   45.32
301            1 2024-12-20         DB        Devlet Bonusu  30.15
302            ...
303        """
304        return self._provider.get_allocation(self._fund_code, fund_type=self.fund_type)

Get current portfolio allocation (asset breakdown) for last 7 days.

For longer periods, use allocation_history() method.

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

Examples:

fund = Fund("AAK") fund.allocation Date asset_type asset_name weight 0 2024-12-20 HS Hisse Senedi 45.32 1 2024-12-20 DB Devlet Bonusu 30.15 ...

def allocation_history( self, period: str = '1mo', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None) -> pandas.core.frame.DataFrame:
306    def allocation_history(
307        self,
308        period: str = "1mo",
309        start: datetime | str | None = None,
310        end: datetime | str | None = None,
311    ) -> pd.DataFrame:
312        """
313        Get historical portfolio allocation (asset breakdown).
314
315        Note: TEFAS API supports maximum ~100 days (3 months) of data.
316
317        Args:
318            period: How much data to fetch. Valid periods:
319                    1d, 5d, 1mo, 3mo (max ~100 days).
320                    Ignored if start is provided.
321            start: Start date (string or datetime).
322            end: End date (string or datetime). Defaults to today.
323
324        Returns:
325            DataFrame with columns: Date, asset_type, asset_name, weight.
326
327        Examples:
328            >>> fund = Fund("AAK")
329            >>> fund.allocation_history(period="1mo")  # Last month
330            >>> fund.allocation_history(period="3mo")  # Last 3 months (max)
331            >>> fund.allocation_history(start="2024-10-01", end="2024-12-31")
332        """
333        start_dt = self._parse_date(start) if start else None
334        end_dt = self._parse_date(end) if end else None
335
336        # If no start date, calculate from period
337        if start_dt is None:
338            from datetime import timedelta
339            end_dt = end_dt or datetime.now()
340            days = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90}.get(period, 30)
341            # Cap at 100 days (API limit)
342            days = min(days, 100)
343            start_dt = end_dt - timedelta(days=days)
344
345        return self._provider.get_allocation(
346            fund_code=self._fund_code,
347            start=start_dt,
348            end=end_dt,
349            fund_type=self.fund_type,
350        )

Get historical portfolio allocation (asset breakdown).

Note: TEFAS API supports maximum ~100 days (3 months) of data.

Args: period: How much data to fetch. Valid periods: 1d, 5d, 1mo, 3mo (max ~100 days). Ignored if start is provided. start: Start date (string or datetime). end: End date (string or datetime). Defaults to today.

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

Examples:

fund = Fund("AAK") fund.allocation_history(period="1mo") # Last month fund.allocation_history(period="3mo") # Last 3 months (max) fund.allocation_history(start="2024-10-01", end="2024-12-31")

def history( self, period: str = '1mo', start: datetime.datetime | str | None = None, end: datetime.datetime | str | None = None) -> pandas.core.frame.DataFrame:
352    def history(
353        self,
354        period: str = "1mo",
355        start: datetime | str | None = None,
356        end: datetime | str | None = None,
357    ) -> pd.DataFrame:
358        """
359        Get historical price data.
360
361        Args:
362            period: How much data to fetch. Valid periods:
363                    1d, 5d, 1mo, 3mo, 6mo, 1y.
364                    Ignored if start is provided.
365            start: Start date (string or datetime).
366            end: End date (string or datetime). Defaults to now.
367
368        Returns:
369            DataFrame with columns: Price, FundSize, Investors.
370            Index is the Date.
371
372        Examples:
373            >>> fund = Fund("AAK")
374            >>> fund.history(period="1mo")  # Last month
375            >>> fund.history(period="1y")  # Last year
376            >>> fund.history(start="2024-01-01", end="2024-06-30")  # Date range
377        """
378        start_dt = self._parse_date(start) if start else None
379        end_dt = self._parse_date(end) if end else None
380
381        return self._provider.get_history(
382            fund_code=self._fund_code,
383            period=period,
384            start=start_dt,
385            end=end_dt,
386            fund_type=self.fund_type,
387        )

Get historical price data.

Args: 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 now.

Returns: DataFrame with columns: Price, FundSize, Investors. Index is the Date.

Examples:

fund = Fund("AAK") fund.history(period="1mo") # Last month fund.history(period="1y") # Last year fund.history(start="2024-01-01", end="2024-06-30") # Date range

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

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:
535    def get_holdings(
536        self,
537        api_key: str,
538        period: str | None = None,
539    ) -> pd.DataFrame:
540        """
541        Get detailed portfolio holdings (individual securities).
542
543        Returns the specific stocks, ETFs, and funds held by this fund,
544        with their weights and ISIN codes. Data is sourced from KAP
545        "Portföy Dağılım Raporu" (Portfolio Distribution Report) disclosures.
546
547        Uses OpenRouter LLM for PDF parsing.
548
549        Args:
550            api_key: OpenRouter API key for LLM parsing.
551                    Get your free API key at: https://openrouter.ai/
552            period: Optional period in format "YYYY-MM" (e.g., "2025-12").
553                   If None, returns the most recent holdings.
554
555        Returns:
556            DataFrame with columns:
557            - symbol: Security symbol (e.g., "GOOGL", "THYAO")
558            - isin: ISIN code
559            - name: Full security name
560            - weight: Portfolio weight (%)
561            - type: Holding type ('stock', 'etf', 'fund', 'viop', etc.)
562            - country: Country ('TR', 'US', or None)
563            - value: Market value in TRY
564
565        Raises:
566            DataNotAvailableError: If holdings data not available.
567            APIError: If LLM parsing fails.
568            ImportError: If required packages are not installed.
569
570        Examples:
571            >>> fund = bp.Fund("YAY")
572            >>> fund.get_holdings(api_key="sk-or-v1-...")
573               symbol              isin                              name  weight   type country         value
574            0   GOOGL  US02079K3059             ALPHABET INC CL A    6.76  stock      US  82478088.0
575            1    AVGO  US11135F1012             BROADCOM INC          5.11  stock      US  62345678.0
576            ...
577
578            >>> # Get holdings for specific period
579            >>> fund.get_holdings(api_key="sk-or-v1-...", period="2025-12")
580
581            >>> # Filter by type
582            >>> holdings = fund.get_holdings(api_key="sk-or-v1-...")
583            >>> holdings[holdings['type'] == 'stock']
584        """
585        from borsapy._providers.kap_holdings import get_kap_holdings_provider
586
587        provider = get_kap_holdings_provider()
588        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:
 33class Eurobond:
 34    """Single Turkish sovereign Eurobond interface.
 35
 36    Provides access to bond data including prices, yields,
 37    maturity, and other characteristics.
 38
 39    Attributes:
 40        isin: ISIN code of the bond.
 41        maturity: Maturity date.
 42        days_to_maturity: Days until maturity.
 43        currency: Bond currency (USD or EUR).
 44        bid_price: Bid price (buying price).
 45        bid_yield: Bid yield (buying yield).
 46        ask_price: Ask price (selling price).
 47        ask_yield: Ask yield (selling yield).
 48        info: All bond data as dictionary.
 49
 50    Examples:
 51        >>> bond = Eurobond("US900123DG28")
 52        >>> bond.bid_yield
 53        6.55
 54        >>> bond.currency
 55        'USD'
 56    """
 57
 58    def __init__(self, isin: str):
 59        """Initialize Eurobond by ISIN.
 60
 61        Args:
 62            isin: ISIN code (e.g., "US900123DG28").
 63
 64        Raises:
 65            DataNotAvailableError: If bond not found.
 66        """
 67        self._isin = isin.upper()
 68        self._provider = get_eurobond_provider()
 69        self._data_cache: dict | None = None
 70
 71    @property
 72    def _data(self) -> dict:
 73        """Lazy-loaded bond data."""
 74        if self._data_cache is None:
 75            self._data_cache = self._provider.get_eurobond(self._isin)
 76            if self._data_cache is None:
 77                raise DataNotAvailableError(f"Eurobond not found: {self._isin}")
 78        return self._data_cache
 79
 80    @property
 81    def isin(self) -> str:
 82        """ISIN code of the bond."""
 83        return self._data["isin"]
 84
 85    @property
 86    def maturity(self) -> datetime | None:
 87        """Maturity date of the bond."""
 88        return self._data.get("maturity")
 89
 90    @property
 91    def days_to_maturity(self) -> int:
 92        """Number of days until maturity."""
 93        return self._data.get("days_to_maturity", 0)
 94
 95    @property
 96    def currency(self) -> str:
 97        """Bond currency (USD or EUR)."""
 98        return self._data.get("currency", "")
 99
100    @property
101    def bid_price(self) -> float | None:
102        """Bid price (buying price)."""
103        return self._data.get("bid_price")
104
105    @property
106    def bid_yield(self) -> float | None:
107        """Bid yield (buying yield) as percentage."""
108        return self._data.get("bid_yield")
109
110    @property
111    def ask_price(self) -> float | None:
112        """Ask price (selling price)."""
113        return self._data.get("ask_price")
114
115    @property
116    def ask_yield(self) -> float | None:
117        """Ask yield (selling yield) as percentage."""
118        return self._data.get("ask_yield")
119
120    @property
121    def info(self) -> dict:
122        """All bond data as dictionary.
123
124        Returns:
125            Dict with all bond attributes.
126        """
127        return self._data.copy()
128
129    def __repr__(self) -> str:
130        """String representation."""
131        try:
132            maturity_year = self.maturity.year if self.maturity else "?"
133            return f"Eurobond({self._isin}, {self.currency}, {maturity_year}, yield={self.bid_yield}%)"
134        except DataNotAvailableError:
135            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)
58    def __init__(self, isin: str):
59        """Initialize Eurobond by ISIN.
60
61        Args:
62            isin: ISIN code (e.g., "US900123DG28").
63
64        Raises:
65            DataNotAvailableError: If bond not found.
66        """
67        self._isin = isin.upper()
68        self._provider = get_eurobond_provider()
69        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
80    @property
81    def isin(self) -> str:
82        """ISIN code of the bond."""
83        return self._data["isin"]

ISIN code of the bond.

maturity: datetime.datetime | None
85    @property
86    def maturity(self) -> datetime | None:
87        """Maturity date of the bond."""
88        return self._data.get("maturity")

Maturity date of the bond.

days_to_maturity: int
90    @property
91    def days_to_maturity(self) -> int:
92        """Number of days until maturity."""
93        return self._data.get("days_to_maturity", 0)

Number of days until maturity.

currency: str
95    @property
96    def currency(self) -> str:
97        """Bond currency (USD or EUR)."""
98        return self._data.get("currency", "")

Bond currency (USD or EUR).

bid_price: float | None
100    @property
101    def bid_price(self) -> float | None:
102        """Bid price (buying price)."""
103        return self._data.get("bid_price")

Bid price (buying price).

bid_yield: float | None
105    @property
106    def bid_yield(self) -> float | None:
107        """Bid yield (buying yield) as percentage."""
108        return self._data.get("bid_yield")

Bid yield (buying yield) as percentage.

ask_price: float | None
110    @property
111    def ask_price(self) -> float | None:
112        """Ask price (selling price)."""
113        return self._data.get("ask_price")

Ask price (selling price).

ask_yield: float | None
115    @property
116    def ask_yield(self) -> float | None:
117        """Ask yield (selling yield) as percentage."""
118        return self._data.get("ask_yield")

Ask yield (selling yield) as percentage.

info: dict
120    @property
121    def info(self) -> dict:
122        """All bond data as dictionary.
123
124        Returns:
125            Dict with all bond attributes.
126        """
127        return self._data.copy()

All bond data as dictionary.

Returns: Dict with all bond attributes.

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 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]]:
594def search_funds(query: str, limit: int = 20) -> list[dict[str, Any]]:
595    """
596    Search for funds by name or code.
597
598    Args:
599        query: Search query (fund code or name)
600        limit: Maximum number of results
601
602    Returns:
603        List of matching funds with fund_code, name, fund_type, return_1y.
604
605    Examples:
606        >>> import borsapy as bp
607        >>> bp.search_funds("ak portföy")
608        [{'fund_code': 'AAK', 'name': 'Ak Portföy...', ...}, ...]
609        >>> bp.search_funds("TTE")
610        [{'fund_code': 'TTE', 'name': 'Türkiye...', ...}]
611    """
612    provider = get_tefas_provider()
613    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:
616def screen_funds(
617    fund_type: str = "YAT",
618    founder: str | None = None,
619    min_return_1m: float | None = None,
620    min_return_3m: float | None = None,
621    min_return_6m: float | None = None,
622    min_return_ytd: float | None = None,
623    min_return_1y: float | None = None,
624    min_return_3y: float | None = None,
625    limit: int = 50,
626) -> pd.DataFrame:
627    """
628    Screen funds based on fund type and return criteria.
629
630    Args:
631        fund_type: Fund type filter:
632            - "YAT": Investment Funds (Yatırım Fonları) - default
633            - "EMK": Pension Funds (Emeklilik Fonları)
634        founder: Filter by fund management company code (e.g., "AKP", "GPY", "ISP")
635        min_return_1m: Minimum 1-month return (%)
636        min_return_3m: Minimum 3-month return (%)
637        min_return_6m: Minimum 6-month return (%)
638        min_return_ytd: Minimum year-to-date return (%)
639        min_return_1y: Minimum 1-year return (%)
640        min_return_3y: Minimum 3-year return (%)
641        limit: Maximum number of results (default: 50)
642
643    Returns:
644        DataFrame with funds matching the criteria, sorted by 1-year return.
645
646    Examples:
647        >>> import borsapy as bp
648        >>> bp.screen_funds(fund_type="EMK")  # All pension funds
649           fund_code                    name  return_1y  ...
650
651        >>> bp.screen_funds(min_return_1y=50)  # Funds with >50% 1Y return
652           fund_code                    name  return_1y  ...
653
654        >>> bp.screen_funds(fund_type="EMK", min_return_ytd=20)
655           fund_code                    name  return_ytd  ...
656    """
657    provider = get_tefas_provider()
658    results = provider.screen_funds(
659        fund_type=fund_type,
660        founder=founder,
661        min_return_1m=min_return_1m,
662        min_return_3m=min_return_3m,
663        min_return_6m=min_return_6m,
664        min_return_ytd=min_return_ytd,
665        min_return_1y=min_return_1y,
666        min_return_3y=min_return_3y,
667        limit=limit,
668    )
669
670    if not results:
671        return pd.DataFrame(columns=["fund_code", "name", "fund_type", "return_1y"])
672
673    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]:
676def compare_funds(fund_codes: list[str]) -> dict[str, Any]:
677    """
678    Compare multiple funds side by side.
679
680    Args:
681        fund_codes: List of TEFAS fund codes to compare (max 10)
682
683    Returns:
684        Dictionary with:
685        - funds: List of fund details with performance metrics
686        - rankings: Ranking by different criteria (by_return_1y, by_return_ytd, by_size, by_risk_asc)
687        - summary: Aggregate statistics (avg_return_1y, best/worst returns, total_size)
688
689    Examples:
690        >>> import borsapy as bp
691        >>> result = bp.compare_funds(["AAK", "TTE", "YAF"])
692        >>> result['rankings']['by_return_1y']
693        ['TTE', 'YAF', 'AAK']
694
695        >>> result['summary']
696        {'fund_count': 3, 'avg_return_1y': 45.2, 'best_return_1y': 72.1, ...}
697
698        >>> for fund in result['funds']:
699        ...     print(f"{fund['fund_code']}: {fund['return_1y']}%")
700        AAK: 32.5%
701        TTE: 72.1%
702        YAF: 31.0%
703    """
704    provider = get_tefas_provider()
705    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:
708def management_fees(
709    fund_type: str = "YAT",
710    founder: str | None = None,
711) -> pd.DataFrame:
712    """
713    Get management fee data for all funds.
714
715    Args:
716        fund_type: Fund type filter:
717            - "YAT": Investment Funds (Yatırım Fonları) - default
718            - "EMK": Pension Funds (Emeklilik Fonları)
719        founder: Filter by founder company code (e.g., "AKP", "GPY")
720
721    Returns:
722        DataFrame with columns: fund_code, name, fund_category, founder_code,
723        applied_fee, prospectus_fee, max_expense_ratio, annual_return.
724
725    Examples:
726        >>> import borsapy as bp
727        >>> df = bp.management_fees()
728        >>> df = bp.management_fees(fund_type="EMK")
729        >>> df = bp.management_fees(founder="AKP")
730    """
731    provider = get_tefas_provider()
732    results = provider.get_management_fees(fund_type=fund_type, founder=founder)
733
734    if not results:
735        return pd.DataFrame(columns=[
736            "fund_code", "name", "fund_category", "founder_code",
737            "applied_fee", "prospectus_fee", "max_expense_ratio", "annual_return",
738        ])
739
740    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:
138def eurobonds(currency: str | None = None) -> pd.DataFrame:
139    """Get all Turkish sovereign Eurobonds as DataFrame.
140
141    Args:
142        currency: Optional filter by currency ("USD" or "EUR").
143
144    Returns:
145        DataFrame with columns: isin, maturity, days_to_maturity,
146        currency, bid_price, bid_yield, ask_price, ask_yield.
147
148    Examples:
149        >>> import borsapy as bp
150        >>> bp.eurobonds()                # All Eurobonds
151        >>> bp.eurobonds(currency="USD")  # USD bonds only
152        >>> bp.eurobonds(currency="EUR")  # EUR bonds only
153    """
154    provider = get_eurobond_provider()
155    data = provider.get_eurobonds(currency=currency)
156
157    if not data:
158        return pd.DataFrame(
159            columns=[
160                "isin",
161                "maturity",
162                "days_to_maturity",
163                "currency",
164                "bid_price",
165                "bid_yield",
166                "ask_price",
167                "ask_yield",
168            ]
169        )
170
171    df = pd.DataFrame(data)
172    df = df.sort_values("maturity")
173    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 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:
484class Backtest:
485    """
486    Backtest engine for evaluating trading strategies.
487
488    Runs a strategy function over historical data and calculates
489    comprehensive performance metrics.
490
491    Attributes:
492        symbol: Stock symbol to backtest.
493        strategy: Strategy function to evaluate.
494        period: Historical data period.
495        interval: Data interval (e.g., "1d", "1h").
496        capital: Initial capital.
497        commission: Commission rate per trade (e.g., 0.001 = 0.1%).
498        indicators: List of indicators to calculate.
499
500    Examples:
501        >>> def my_strategy(candle, position, indicators):
502        ...     if indicators['rsi'] < 30:
503        ...         return 'BUY'
504        ...     elif indicators['rsi'] > 70:
505        ...         return 'SELL'
506        ...     return 'HOLD'
507
508        >>> bt = Backtest("THYAO", my_strategy, period="1y")
509        >>> result = bt.run()
510        >>> print(result.sharpe_ratio)
511    """
512
513    # Indicator period warmup
514    WARMUP_PERIOD = 50
515
516    def __init__(
517        self,
518        symbol: str,
519        strategy: StrategyFunc,
520        period: str = "1y",
521        interval: str = "1d",
522        capital: float = 100_000.0,
523        commission: float = 0.001,
524        indicators: list[str] | None = None,
525        slippage: float = 0.0,  # Future use
526    ):
527        """
528        Initialize Backtest.
529
530        Args:
531            symbol: Stock symbol (e.g., "THYAO").
532            strategy: Strategy function with signature:
533                      strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
534            period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y).
535            interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d).
536            capital: Initial capital in TL.
537            commission: Commission rate per trade (0.001 = 0.1%).
538            indicators: List of indicators to calculate. Options:
539                       'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200',
540                       'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger',
541                       'atr', 'atr_20', 'stochastic', 'adx'
542            slippage: Slippage per trade (for future use).
543        """
544        self.symbol = symbol.upper()
545        self.strategy = strategy
546        self.period = period
547        self.interval = interval
548        self.capital = capital
549        self.commission = commission
550        self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"]
551        self.slippage = slippage
552
553        # Strategy name for reporting
554        self._strategy_name = getattr(strategy, "__name__", "custom_strategy")
555
556        # Data storage
557        self._df: pd.DataFrame | None = None
558        self._df_with_indicators: pd.DataFrame | None = None
559
560    def _load_data(self) -> pd.DataFrame:
561        """Load historical data from Ticker."""
562        from borsapy.ticker import Ticker
563
564        ticker = Ticker(self.symbol)
565        df = ticker.history(period=self.period, interval=self.interval)
566
567        if df is None or df.empty:
568            raise ValueError(f"No historical data available for {self.symbol}")
569
570        return df
571
572    def _calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
573        """Add indicator columns to DataFrame."""
574        from borsapy.technical import (
575            calculate_adx,
576            calculate_atr,
577            calculate_bollinger_bands,
578            calculate_ema,
579            calculate_macd,
580            calculate_rsi,
581            calculate_sma,
582            calculate_stochastic,
583        )
584
585        result = df.copy()
586
587        for ind in self.indicators:
588            ind_lower = ind.lower()
589
590            # RSI variants
591            if ind_lower == "rsi":
592                result["rsi"] = calculate_rsi(df, period=14)
593            elif ind_lower.startswith("rsi_"):
594                try:
595                    period = int(ind_lower.split("_")[1])
596                    result[f"rsi_{period}"] = calculate_rsi(df, period=period)
597                except (IndexError, ValueError):
598                    pass
599
600            # SMA variants
601            elif ind_lower.startswith("sma_"):
602                try:
603                    period = int(ind_lower.split("_")[1])
604                    result[f"sma_{period}"] = calculate_sma(df, period=period)
605                except (IndexError, ValueError):
606                    pass
607
608            # EMA variants
609            elif ind_lower.startswith("ema_"):
610                try:
611                    period = int(ind_lower.split("_")[1])
612                    result[f"ema_{period}"] = calculate_ema(df, period=period)
613                except (IndexError, ValueError):
614                    pass
615
616            # MACD
617            elif ind_lower == "macd":
618                macd_df = calculate_macd(df)
619                result["macd"] = macd_df["MACD"]
620                result["macd_signal"] = macd_df["Signal"]
621                result["macd_histogram"] = macd_df["Histogram"]
622
623            # Bollinger Bands
624            elif ind_lower in ("bollinger", "bb"):
625                bb_df = calculate_bollinger_bands(df)
626                result["bb_upper"] = bb_df["BB_Upper"]
627                result["bb_middle"] = bb_df["BB_Middle"]
628                result["bb_lower"] = bb_df["BB_Lower"]
629
630            # ATR variants
631            elif ind_lower == "atr":
632                result["atr"] = calculate_atr(df, period=14)
633            elif ind_lower.startswith("atr_"):
634                try:
635                    period = int(ind_lower.split("_")[1])
636                    result[f"atr_{period}"] = calculate_atr(df, period=period)
637                except (IndexError, ValueError):
638                    pass
639
640            # Stochastic
641            elif ind_lower in ("stochastic", "stoch"):
642                stoch_df = calculate_stochastic(df)
643                result["stoch_k"] = stoch_df["Stoch_K"]
644                result["stoch_d"] = stoch_df["Stoch_D"]
645
646            # ADX
647            elif ind_lower == "adx":
648                result["adx"] = calculate_adx(df, period=14)
649
650        return result
651
652    def _get_indicators_at(self, idx: int) -> dict[str, float]:
653        """Get indicator values at specific index."""
654        if self._df_with_indicators is None:
655            return {}
656
657        row = self._df_with_indicators.iloc[idx]
658        indicators = {}
659
660        # Extract all non-OHLCV columns as indicators
661        exclude_cols = {"Open", "High", "Low", "Close", "Volume", "Adj Close"}
662
663        for col in self._df_with_indicators.columns:
664            if col not in exclude_cols:
665                val = row[col]
666                if pd.notna(val):
667                    indicators[col] = float(val)
668
669        return indicators
670
671    def _build_candle(self, idx: int) -> dict[str, Any]:
672        """Build candle dict from DataFrame row."""
673        if self._df is None:
674            return {}
675
676        row = self._df.iloc[idx]
677        timestamp = self._df.index[idx]
678
679        if isinstance(timestamp, pd.Timestamp):
680            timestamp = timestamp.to_pydatetime()
681
682        return {
683            "timestamp": timestamp,
684            "open": float(row["Open"]),
685            "high": float(row["High"]),
686            "low": float(row["Low"]),
687            "close": float(row["Close"]),
688            "volume": float(row.get("Volume", 0)) if "Volume" in row else 0,
689            "_index": idx,
690        }
691
692    def run(self) -> BacktestResult:
693        """
694        Run the backtest.
695
696        Returns:
697            BacktestResult with all performance metrics.
698
699        Raises:
700            ValueError: If no data available for symbol.
701        """
702        # Load data
703        self._df = self._load_data()
704        self._df_with_indicators = self._calculate_indicators(self._df)
705
706        # Initialize state
707        cash = self.capital
708        position: Position = None
709        shares = 0.0
710        trades: list[Trade] = []
711        current_trade: Trade | None = None
712
713        # Track equity curve
714        equity_values = []
715        dates = []
716
717        # Buy & hold tracking
718        initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD]
719        bh_shares = self.capital / initial_price
720
721        # Run simulation
722        for idx in range(self.WARMUP_PERIOD, len(self._df)):
723            candle = self._build_candle(idx)
724            indicators = self._get_indicators_at(idx)
725            price = candle["close"]
726            timestamp = candle["timestamp"]
727
728            # Get strategy signal
729            try:
730                signal = self.strategy(candle, position, indicators)
731            except Exception:
732                signal = "HOLD"
733
734            # Execute trades
735            if signal == "BUY" and position is None:
736                # Calculate shares to buy (use all available cash)
737                entry_commission = cash * self.commission
738                available = cash - entry_commission
739                shares = available / price
740
741                current_trade = Trade(
742                    entry_time=timestamp,
743                    entry_price=price,
744                    side="long",
745                    shares=shares,
746                    commission=entry_commission,
747                )
748
749                cash = 0.0
750                position = "long"
751
752            elif signal == "SELL" and position == "long" and current_trade is not None:
753                # Close position
754                exit_value = shares * price
755                exit_commission = exit_value * self.commission
756
757                current_trade.exit_time = timestamp
758                current_trade.exit_price = price
759                current_trade.commission += exit_commission
760
761                trades.append(current_trade)
762
763                cash = exit_value - exit_commission
764                shares = 0.0
765                position = None
766                current_trade = None
767
768            # Track equity
769            if position == "long":
770                equity = shares * price
771            else:
772                equity = cash
773
774            equity_values.append(equity)
775            dates.append(timestamp)
776
777        # Close any open position at end
778        if position == "long" and current_trade is not None:
779            final_price = self._df["Close"].iloc[-1]
780            exit_value = shares * final_price
781            exit_commission = exit_value * self.commission
782
783            current_trade.exit_time = self._df.index[-1]
784            if isinstance(current_trade.exit_time, pd.Timestamp):
785                current_trade.exit_time = current_trade.exit_time.to_pydatetime()
786            current_trade.exit_price = final_price
787            current_trade.commission += exit_commission
788
789            trades.append(current_trade)
790
791        # Build curves
792        equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates))
793
794        # Calculate drawdown curve
795        running_max = equity_curve.cummax()
796        drawdown_curve = (equity_curve - running_max) / running_max
797
798        # Buy & hold curve
799        bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares
800        buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates))
801
802        return BacktestResult(
803            symbol=self.symbol,
804            period=self.period,
805            interval=self.interval,
806            strategy_name=self._strategy_name,
807            initial_capital=self.capital,
808            commission=self.commission,
809            trades=trades,
810            equity_curve=equity_curve,
811            drawdown_curve=drawdown_curve,
812            buy_hold_curve=buy_hold_curve,
813        )

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, Optional[Literal['long', 'short']], dict], Optional[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)
516    def __init__(
517        self,
518        symbol: str,
519        strategy: StrategyFunc,
520        period: str = "1y",
521        interval: str = "1d",
522        capital: float = 100_000.0,
523        commission: float = 0.001,
524        indicators: list[str] | None = None,
525        slippage: float = 0.0,  # Future use
526    ):
527        """
528        Initialize Backtest.
529
530        Args:
531            symbol: Stock symbol (e.g., "THYAO").
532            strategy: Strategy function with signature:
533                      strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
534            period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y).
535            interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d).
536            capital: Initial capital in TL.
537            commission: Commission rate per trade (0.001 = 0.1%).
538            indicators: List of indicators to calculate. Options:
539                       'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200',
540                       'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger',
541                       'atr', 'atr_20', 'stochastic', 'adx'
542            slippage: Slippage per trade (for future use).
543        """
544        self.symbol = symbol.upper()
545        self.strategy = strategy
546        self.period = period
547        self.interval = interval
548        self.capital = capital
549        self.commission = commission
550        self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"]
551        self.slippage = slippage
552
553        # Strategy name for reporting
554        self._strategy_name = getattr(strategy, "__name__", "custom_strategy")
555
556        # Data storage
557        self._df: pd.DataFrame | None = None
558        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:
692    def run(self) -> BacktestResult:
693        """
694        Run the backtest.
695
696        Returns:
697            BacktestResult with all performance metrics.
698
699        Raises:
700            ValueError: If no data available for symbol.
701        """
702        # Load data
703        self._df = self._load_data()
704        self._df_with_indicators = self._calculate_indicators(self._df)
705
706        # Initialize state
707        cash = self.capital
708        position: Position = None
709        shares = 0.0
710        trades: list[Trade] = []
711        current_trade: Trade | None = None
712
713        # Track equity curve
714        equity_values = []
715        dates = []
716
717        # Buy & hold tracking
718        initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD]
719        bh_shares = self.capital / initial_price
720
721        # Run simulation
722        for idx in range(self.WARMUP_PERIOD, len(self._df)):
723            candle = self._build_candle(idx)
724            indicators = self._get_indicators_at(idx)
725            price = candle["close"]
726            timestamp = candle["timestamp"]
727
728            # Get strategy signal
729            try:
730                signal = self.strategy(candle, position, indicators)
731            except Exception:
732                signal = "HOLD"
733
734            # Execute trades
735            if signal == "BUY" and position is None:
736                # Calculate shares to buy (use all available cash)
737                entry_commission = cash * self.commission
738                available = cash - entry_commission
739                shares = available / price
740
741                current_trade = Trade(
742                    entry_time=timestamp,
743                    entry_price=price,
744                    side="long",
745                    shares=shares,
746                    commission=entry_commission,
747                )
748
749                cash = 0.0
750                position = "long"
751
752            elif signal == "SELL" and position == "long" and current_trade is not None:
753                # Close position
754                exit_value = shares * price
755                exit_commission = exit_value * self.commission
756
757                current_trade.exit_time = timestamp
758                current_trade.exit_price = price
759                current_trade.commission += exit_commission
760
761                trades.append(current_trade)
762
763                cash = exit_value - exit_commission
764                shares = 0.0
765                position = None
766                current_trade = None
767
768            # Track equity
769            if position == "long":
770                equity = shares * price
771            else:
772                equity = cash
773
774            equity_values.append(equity)
775            dates.append(timestamp)
776
777        # Close any open position at end
778        if position == "long" and current_trade is not None:
779            final_price = self._df["Close"].iloc[-1]
780            exit_value = shares * final_price
781            exit_commission = exit_value * self.commission
782
783            current_trade.exit_time = self._df.index[-1]
784            if isinstance(current_trade.exit_time, pd.Timestamp):
785                current_trade.exit_time = current_trade.exit_time.to_pydatetime()
786            current_trade.exit_price = final_price
787            current_trade.commission += exit_commission
788
789            trades.append(current_trade)
790
791        # Build curves
792        equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates))
793
794        # Calculate drawdown curve
795        running_max = equity_curve.cummax()
796        drawdown_curve = (equity_curve - running_max) / running_max
797
798        # Buy & hold curve
799        bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares
800        buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates))
801
802        return BacktestResult(
803            symbol=self.symbol,
804            period=self.period,
805            interval=self.interval,
806            strategy_name=self._strategy_name,
807            initial_capital=self.capital,
808            commission=self.commission,
809            trades=trades,
810            equity_curve=equity_curve,
811            drawdown_curve=drawdown_curve,
812            buy_hold_curve=buy_hold_curve,
813        )

Run the backtest.

Returns: BacktestResult with all performance metrics.

Raises: ValueError: If no data available for symbol.

@dataclass
class BacktestResult:
127@dataclass
128class BacktestResult:
129    """
130    Comprehensive backtest results with performance metrics.
131
132    Follows TradingView/Mathieu2301 result format for familiarity.
133
134    Attributes:
135        symbol: Traded symbol.
136        period: Test period (e.g., "1y").
137        interval: Data interval (e.g., "1d").
138        strategy_name: Name of the strategy function.
139        initial_capital: Starting capital.
140        commission: Commission rate used.
141        trades: List of executed trades.
142        equity_curve: Daily equity values.
143        drawdown_curve: Daily drawdown values.
144        buy_hold_curve: Buy & hold comparison values.
145    """
146
147    # Identification
148    symbol: str
149    period: str
150    interval: str
151    strategy_name: str
152
153    # Configuration
154    initial_capital: float
155    commission: float
156
157    # Results
158    trades: list[Trade] = field(default_factory=list)
159    equity_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
160    drawdown_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
161    buy_hold_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
162
163    # === Performance Properties ===
164
165    @property
166    def final_equity(self) -> float:
167        """Final portfolio value."""
168        if self.equity_curve.empty:
169            return self.initial_capital
170        return float(self.equity_curve.iloc[-1])
171
172    @property
173    def net_profit(self) -> float:
174        """Net profit in currency units."""
175        return self.final_equity - self.initial_capital
176
177    @property
178    def net_profit_pct(self) -> float:
179        """Net profit as percentage."""
180        if self.initial_capital == 0:
181            return 0.0
182        return (self.net_profit / self.initial_capital) * 100
183
184    @property
185    def total_trades(self) -> int:
186        """Total number of closed trades."""
187        return len([t for t in self.trades if t.is_closed])
188
189    @property
190    def winning_trades(self) -> int:
191        """Number of profitable trades."""
192        return len([t for t in self.trades if t.is_closed and (t.profit or 0) > 0])
193
194    @property
195    def losing_trades(self) -> int:
196        """Number of losing trades."""
197        return len([t for t in self.trades if t.is_closed and (t.profit or 0) <= 0])
198
199    @property
200    def win_rate(self) -> float:
201        """Percentage of winning trades."""
202        if self.total_trades == 0:
203            return 0.0
204        return (self.winning_trades / self.total_trades) * 100
205
206    @property
207    def profit_factor(self) -> float:
208        """Ratio of gross profits to gross losses."""
209        gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0)
210        gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0))
211        if gross_loss == 0:
212            return float("inf") if gross_profit > 0 else 0.0
213        return gross_profit / gross_loss
214
215    @property
216    def avg_trade(self) -> float:
217        """Average profit per trade."""
218        closed = [t for t in self.trades if t.is_closed]
219        if not closed:
220            return 0.0
221        return sum(t.profit or 0 for t in closed) / len(closed)
222
223    @property
224    def avg_winning_trade(self) -> float:
225        """Average profit of winning trades."""
226        winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0]
227        if not winners:
228            return 0.0
229        return sum(t.profit or 0 for t in winners) / len(winners)
230
231    @property
232    def avg_losing_trade(self) -> float:
233        """Average loss of losing trades."""
234        losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0]
235        if not losers:
236            return 0.0
237        return sum(t.profit or 0 for t in losers) / len(losers)
238
239    @property
240    def max_consecutive_wins(self) -> int:
241        """Maximum consecutive winning trades."""
242        return self._max_consecutive(lambda t: (t.profit or 0) > 0)
243
244    @property
245    def max_consecutive_losses(self) -> int:
246        """Maximum consecutive losing trades."""
247        return self._max_consecutive(lambda t: (t.profit or 0) <= 0)
248
249    def _max_consecutive(self, condition: Callable[[Trade], bool]) -> int:
250        """Helper to find max consecutive trades matching condition."""
251        closed = [t for t in self.trades if t.is_closed]
252        if not closed:
253            return 0
254        max_count = 0
255        current_count = 0
256        for trade in closed:
257            if condition(trade):
258                current_count += 1
259                max_count = max(max_count, current_count)
260            else:
261                current_count = 0
262        return max_count
263
264    @property
265    def sharpe_ratio(self) -> float:
266        """
267        Sharpe ratio (risk-adjusted return).
268
269        Assumes 252 trading days and risk-free rate from current 10Y bond.
270        """
271        if self.equity_curve.empty or len(self.equity_curve) < 2:
272            return float("nan")
273
274        returns = self.equity_curve.pct_change().dropna()
275        if returns.std() == 0:
276            return float("nan")
277
278        # Get risk-free rate
279        try:
280            from borsapy.bond import risk_free_rate
281
282            rf_annual = risk_free_rate()
283        except Exception:
284            rf_annual = 0.30  # Fallback 30%
285
286        rf_daily = rf_annual / 252
287        excess_returns = returns - rf_daily
288        return float(np.sqrt(252) * excess_returns.mean() / excess_returns.std())
289
290    @property
291    def sortino_ratio(self) -> float:
292        """
293        Sortino ratio (downside risk-adjusted return).
294
295        Uses downside deviation instead of standard deviation.
296        """
297        if self.equity_curve.empty or len(self.equity_curve) < 2:
298            return float("nan")
299
300        returns = self.equity_curve.pct_change().dropna()
301
302        # Get risk-free rate
303        try:
304            from borsapy.bond import risk_free_rate
305
306            rf_annual = risk_free_rate()
307        except Exception:
308            rf_annual = 0.30
309
310        rf_daily = rf_annual / 252
311        excess_returns = returns - rf_daily
312        negative_returns = excess_returns[excess_returns < 0]
313
314        if len(negative_returns) == 0 or negative_returns.std() == 0:
315            return float("inf") if excess_returns.mean() > 0 else float("nan")
316
317        downside_std = negative_returns.std()
318        return float(np.sqrt(252) * excess_returns.mean() / downside_std)
319
320    @property
321    def max_drawdown(self) -> float:
322        """Maximum drawdown as percentage."""
323        if self.drawdown_curve.empty:
324            return 0.0
325        return float(self.drawdown_curve.min()) * 100
326
327    @property
328    def max_drawdown_duration(self) -> int:
329        """Maximum drawdown duration in days."""
330        if self.equity_curve.empty:
331            return 0
332
333        # Find periods where we're in drawdown
334        running_max = self.equity_curve.cummax()
335        in_drawdown = self.equity_curve < running_max
336
337        max_duration = 0
338        current_duration = 0
339
340        for is_dd in in_drawdown:
341            if is_dd:
342                current_duration += 1
343                max_duration = max(max_duration, current_duration)
344            else:
345                current_duration = 0
346
347        return max_duration
348
349    @property
350    def buy_hold_return(self) -> float:
351        """Buy & hold return as percentage."""
352        if self.buy_hold_curve.empty:
353            return 0.0
354        first = self.buy_hold_curve.iloc[0]
355        last = self.buy_hold_curve.iloc[-1]
356        if first == 0:
357            return 0.0
358        return ((last - first) / first) * 100
359
360    @property
361    def vs_buy_hold(self) -> float:
362        """Strategy outperformance vs buy & hold (percentage points)."""
363        return self.net_profit_pct - self.buy_hold_return
364
365    @property
366    def calmar_ratio(self) -> float:
367        """Calmar ratio (annualized return / max drawdown)."""
368        if self.max_drawdown == 0:
369            return float("inf") if self.net_profit_pct > 0 else 0.0
370        # Annualize return (assuming 252 trading days)
371        trading_days = len(self.equity_curve)
372        if trading_days == 0:
373            return 0.0
374        annual_return = self.net_profit_pct * (252 / trading_days)
375        return annual_return / abs(self.max_drawdown)
376
377    # === Export Methods ===
378
379    @property
380    def trades_df(self) -> pd.DataFrame:
381        """Get trades as DataFrame."""
382        if not self.trades:
383            return pd.DataFrame(
384                columns=[
385                    "entry_time",
386                    "entry_price",
387                    "exit_time",
388                    "exit_price",
389                    "side",
390                    "shares",
391                    "commission",
392                    "profit",
393                    "profit_pct",
394                    "duration",
395                ]
396            )
397        return pd.DataFrame([t.to_dict() for t in self.trades])
398
399    def to_dict(self) -> dict[str, Any]:
400        """
401        Export results to dictionary.
402
403        Compatible with TradingView/Mathieu2301 format.
404        """
405        return {
406            # Identification
407            "symbol": self.symbol,
408            "period": self.period,
409            "interval": self.interval,
410            "strategy_name": self.strategy_name,
411            # Configuration
412            "initial_capital": self.initial_capital,
413            "commission": self.commission,
414            # Summary
415            "net_profit": round(self.net_profit, 2),
416            "net_profit_pct": round(self.net_profit_pct, 2),
417            "final_equity": round(self.final_equity, 2),
418            # Trade Statistics
419            "total_trades": self.total_trades,
420            "winning_trades": self.winning_trades,
421            "losing_trades": self.losing_trades,
422            "win_rate": round(self.win_rate, 2),
423            "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf",
424            "avg_trade": round(self.avg_trade, 2),
425            "avg_winning_trade": round(self.avg_winning_trade, 2),
426            "avg_losing_trade": round(self.avg_losing_trade, 2),
427            "max_consecutive_wins": self.max_consecutive_wins,
428            "max_consecutive_losses": self.max_consecutive_losses,
429            # Risk Metrics
430            "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None,
431            "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None,
432            "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None,
433            "max_drawdown": round(self.max_drawdown, 2),
434            "max_drawdown_duration": self.max_drawdown_duration,
435            # Comparison
436            "buy_hold_return": round(self.buy_hold_return, 2),
437            "vs_buy_hold": round(self.vs_buy_hold, 2),
438        }
439
440    def summary(self) -> str:
441        """
442        Generate human-readable performance summary.
443
444        Returns:
445            Formatted summary string.
446        """
447        d = self.to_dict()
448
449        lines = [
450            "=" * 60,
451            f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})",
452            "=" * 60,
453            f"Period: {d['period']} | Interval: {d['interval']}",
454            f"Initial Capital: {d['initial_capital']:,.2f} TL",
455            f"Commission: {d['commission']*100:.2f}%",
456            "",
457            "--- PERFORMANCE ---",
458            f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)",
459            f"Final Equity: {d['final_equity']:,.2f} TL",
460            f"Buy & Hold: {d['buy_hold_return']:+.2f}%",
461            f"vs B&H: {d['vs_buy_hold']:+.2f}%",
462            "",
463            "--- TRADE STATISTICS ---",
464            f"Total Trades: {d['total_trades']}",
465            f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}",
466            f"Win Rate: {d['win_rate']:.1f}%",
467            f"Profit Factor: {d['profit_factor']}",
468            f"Avg Trade: {d['avg_trade']:,.2f} TL",
469            f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL",
470            f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}",
471            "",
472            "--- RISK METRICS ---",
473            f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}",
474            f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}",
475            f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}",
476            f"Max Drawdown: {d['max_drawdown']:.2f}%",
477            f"Max DD Duration: {d['max_drawdown_duration']} days",
478            "=" * 60,
479        ]
480
481        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
165    @property
166    def final_equity(self) -> float:
167        """Final portfolio value."""
168        if self.equity_curve.empty:
169            return self.initial_capital
170        return float(self.equity_curve.iloc[-1])

Final portfolio value.

net_profit: float
172    @property
173    def net_profit(self) -> float:
174        """Net profit in currency units."""
175        return self.final_equity - self.initial_capital

Net profit in currency units.

net_profit_pct: float
177    @property
178    def net_profit_pct(self) -> float:
179        """Net profit as percentage."""
180        if self.initial_capital == 0:
181            return 0.0
182        return (self.net_profit / self.initial_capital) * 100

Net profit as percentage.

total_trades: int
184    @property
185    def total_trades(self) -> int:
186        """Total number of closed trades."""
187        return len([t for t in self.trades if t.is_closed])

Total number of closed trades.

winning_trades: int
189    @property
190    def winning_trades(self) -> int:
191        """Number of profitable trades."""
192        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
194    @property
195    def losing_trades(self) -> int:
196        """Number of losing trades."""
197        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
199    @property
200    def win_rate(self) -> float:
201        """Percentage of winning trades."""
202        if self.total_trades == 0:
203            return 0.0
204        return (self.winning_trades / self.total_trades) * 100

Percentage of winning trades.

profit_factor: float
206    @property
207    def profit_factor(self) -> float:
208        """Ratio of gross profits to gross losses."""
209        gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0)
210        gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0))
211        if gross_loss == 0:
212            return float("inf") if gross_profit > 0 else 0.0
213        return gross_profit / gross_loss

Ratio of gross profits to gross losses.

avg_trade: float
215    @property
216    def avg_trade(self) -> float:
217        """Average profit per trade."""
218        closed = [t for t in self.trades if t.is_closed]
219        if not closed:
220            return 0.0
221        return sum(t.profit or 0 for t in closed) / len(closed)

Average profit per trade.

avg_winning_trade: float
223    @property
224    def avg_winning_trade(self) -> float:
225        """Average profit of winning trades."""
226        winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0]
227        if not winners:
228            return 0.0
229        return sum(t.profit or 0 for t in winners) / len(winners)

Average profit of winning trades.

avg_losing_trade: float
231    @property
232    def avg_losing_trade(self) -> float:
233        """Average loss of losing trades."""
234        losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0]
235        if not losers:
236            return 0.0
237        return sum(t.profit or 0 for t in losers) / len(losers)

Average loss of losing trades.

max_consecutive_wins: int
239    @property
240    def max_consecutive_wins(self) -> int:
241        """Maximum consecutive winning trades."""
242        return self._max_consecutive(lambda t: (t.profit or 0) > 0)

Maximum consecutive winning trades.

max_consecutive_losses: int
244    @property
245    def max_consecutive_losses(self) -> int:
246        """Maximum consecutive losing trades."""
247        return self._max_consecutive(lambda t: (t.profit or 0) <= 0)

Maximum consecutive losing trades.

sharpe_ratio: float
264    @property
265    def sharpe_ratio(self) -> float:
266        """
267        Sharpe ratio (risk-adjusted return).
268
269        Assumes 252 trading days and risk-free rate from current 10Y bond.
270        """
271        if self.equity_curve.empty or len(self.equity_curve) < 2:
272            return float("nan")
273
274        returns = self.equity_curve.pct_change().dropna()
275        if returns.std() == 0:
276            return float("nan")
277
278        # Get risk-free rate
279        try:
280            from borsapy.bond import risk_free_rate
281
282            rf_annual = risk_free_rate()
283        except Exception:
284            rf_annual = 0.30  # Fallback 30%
285
286        rf_daily = rf_annual / 252
287        excess_returns = returns - rf_daily
288        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
290    @property
291    def sortino_ratio(self) -> float:
292        """
293        Sortino ratio (downside risk-adjusted return).
294
295        Uses downside deviation instead of standard deviation.
296        """
297        if self.equity_curve.empty or len(self.equity_curve) < 2:
298            return float("nan")
299
300        returns = self.equity_curve.pct_change().dropna()
301
302        # Get risk-free rate
303        try:
304            from borsapy.bond import risk_free_rate
305
306            rf_annual = risk_free_rate()
307        except Exception:
308            rf_annual = 0.30
309
310        rf_daily = rf_annual / 252
311        excess_returns = returns - rf_daily
312        negative_returns = excess_returns[excess_returns < 0]
313
314        if len(negative_returns) == 0 or negative_returns.std() == 0:
315            return float("inf") if excess_returns.mean() > 0 else float("nan")
316
317        downside_std = negative_returns.std()
318        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
320    @property
321    def max_drawdown(self) -> float:
322        """Maximum drawdown as percentage."""
323        if self.drawdown_curve.empty:
324            return 0.0
325        return float(self.drawdown_curve.min()) * 100

Maximum drawdown as percentage.

max_drawdown_duration: int
327    @property
328    def max_drawdown_duration(self) -> int:
329        """Maximum drawdown duration in days."""
330        if self.equity_curve.empty:
331            return 0
332
333        # Find periods where we're in drawdown
334        running_max = self.equity_curve.cummax()
335        in_drawdown = self.equity_curve < running_max
336
337        max_duration = 0
338        current_duration = 0
339
340        for is_dd in in_drawdown:
341            if is_dd:
342                current_duration += 1
343                max_duration = max(max_duration, current_duration)
344            else:
345                current_duration = 0
346
347        return max_duration

Maximum drawdown duration in days.

buy_hold_return: float
349    @property
350    def buy_hold_return(self) -> float:
351        """Buy & hold return as percentage."""
352        if self.buy_hold_curve.empty:
353            return 0.0
354        first = self.buy_hold_curve.iloc[0]
355        last = self.buy_hold_curve.iloc[-1]
356        if first == 0:
357            return 0.0
358        return ((last - first) / first) * 100

Buy & hold return as percentage.

vs_buy_hold: float
360    @property
361    def vs_buy_hold(self) -> float:
362        """Strategy outperformance vs buy & hold (percentage points)."""
363        return self.net_profit_pct - self.buy_hold_return

Strategy outperformance vs buy & hold (percentage points).

calmar_ratio: float
365    @property
366    def calmar_ratio(self) -> float:
367        """Calmar ratio (annualized return / max drawdown)."""
368        if self.max_drawdown == 0:
369            return float("inf") if self.net_profit_pct > 0 else 0.0
370        # Annualize return (assuming 252 trading days)
371        trading_days = len(self.equity_curve)
372        if trading_days == 0:
373            return 0.0
374        annual_return = self.net_profit_pct * (252 / trading_days)
375        return annual_return / abs(self.max_drawdown)

Calmar ratio (annualized return / max drawdown).

trades_df: pandas.core.frame.DataFrame
379    @property
380    def trades_df(self) -> pd.DataFrame:
381        """Get trades as DataFrame."""
382        if not self.trades:
383            return pd.DataFrame(
384                columns=[
385                    "entry_time",
386                    "entry_price",
387                    "exit_time",
388                    "exit_price",
389                    "side",
390                    "shares",
391                    "commission",
392                    "profit",
393                    "profit_pct",
394                    "duration",
395                ]
396            )
397        return pd.DataFrame([t.to_dict() for t in self.trades])

Get trades as DataFrame.

def to_dict(self) -> dict[str, typing.Any]:
399    def to_dict(self) -> dict[str, Any]:
400        """
401        Export results to dictionary.
402
403        Compatible with TradingView/Mathieu2301 format.
404        """
405        return {
406            # Identification
407            "symbol": self.symbol,
408            "period": self.period,
409            "interval": self.interval,
410            "strategy_name": self.strategy_name,
411            # Configuration
412            "initial_capital": self.initial_capital,
413            "commission": self.commission,
414            # Summary
415            "net_profit": round(self.net_profit, 2),
416            "net_profit_pct": round(self.net_profit_pct, 2),
417            "final_equity": round(self.final_equity, 2),
418            # Trade Statistics
419            "total_trades": self.total_trades,
420            "winning_trades": self.winning_trades,
421            "losing_trades": self.losing_trades,
422            "win_rate": round(self.win_rate, 2),
423            "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf",
424            "avg_trade": round(self.avg_trade, 2),
425            "avg_winning_trade": round(self.avg_winning_trade, 2),
426            "avg_losing_trade": round(self.avg_losing_trade, 2),
427            "max_consecutive_wins": self.max_consecutive_wins,
428            "max_consecutive_losses": self.max_consecutive_losses,
429            # Risk Metrics
430            "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None,
431            "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None,
432            "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None,
433            "max_drawdown": round(self.max_drawdown, 2),
434            "max_drawdown_duration": self.max_drawdown_duration,
435            # Comparison
436            "buy_hold_return": round(self.buy_hold_return, 2),
437            "vs_buy_hold": round(self.vs_buy_hold, 2),
438        }

Export results to dictionary.

Compatible with TradingView/Mathieu2301 format.

def summary(self) -> str:
440    def summary(self) -> str:
441        """
442        Generate human-readable performance summary.
443
444        Returns:
445            Formatted summary string.
446        """
447        d = self.to_dict()
448
449        lines = [
450            "=" * 60,
451            f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})",
452            "=" * 60,
453            f"Period: {d['period']} | Interval: {d['interval']}",
454            f"Initial Capital: {d['initial_capital']:,.2f} TL",
455            f"Commission: {d['commission']*100:.2f}%",
456            "",
457            "--- PERFORMANCE ---",
458            f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)",
459            f"Final Equity: {d['final_equity']:,.2f} TL",
460            f"Buy & Hold: {d['buy_hold_return']:+.2f}%",
461            f"vs B&H: {d['vs_buy_hold']:+.2f}%",
462            "",
463            "--- TRADE STATISTICS ---",
464            f"Total Trades: {d['total_trades']}",
465            f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}",
466            f"Win Rate: {d['win_rate']:.1f}%",
467            f"Profit Factor: {d['profit_factor']}",
468            f"Avg Trade: {d['avg_trade']:,.2f} TL",
469            f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL",
470            f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}",
471            "",
472            "--- RISK METRICS ---",
473            f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}",
474            f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}",
475            f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}",
476            f"Max Drawdown: {d['max_drawdown']:.2f}%",
477            f"Max DD Duration: {d['max_drawdown_duration']} days",
478            "=" * 60,
479        ]
480
481        return "\n".join(lines)

Generate human-readable performance summary.

Returns: Formatted summary string.

@dataclass
class Trade:
 51@dataclass
 52class Trade:
 53    """
 54    Represents a single trade in a backtest.
 55
 56    Attributes:
 57        entry_time: When the trade was opened.
 58        entry_price: Price at entry.
 59        exit_time: When the trade was closed (None if open).
 60        exit_price: Price at exit (None if open).
 61        side: Trade direction ('long' or 'short').
 62        shares: Number of shares traded.
 63        commission: Total commission paid (entry + exit).
 64    """
 65
 66    entry_time: datetime
 67    entry_price: float
 68    exit_time: datetime | None = None
 69    exit_price: float | None = None
 70    side: Literal["long", "short"] = "long"
 71    shares: float = 0.0
 72    commission: float = 0.0
 73
 74    @property
 75    def is_closed(self) -> bool:
 76        """Check if trade is closed."""
 77        return self.exit_time is not None and self.exit_price is not None
 78
 79    @property
 80    def profit(self) -> float | None:
 81        """Calculate profit in currency units (None if open)."""
 82        if not self.is_closed:
 83            return None
 84        assert self.exit_price is not None
 85        if self.side == "long":
 86            gross = (self.exit_price - self.entry_price) * self.shares
 87        else:
 88            gross = (self.entry_price - self.exit_price) * self.shares
 89        return gross - self.commission
 90
 91    @property
 92    def profit_pct(self) -> float | None:
 93        """Calculate profit as percentage (None if open)."""
 94        if not self.is_closed or self.entry_price == 0:
 95            return None
 96        profit = self.profit
 97        if profit is None:
 98            return None
 99        entry_value = self.entry_price * self.shares
100        return (profit / entry_value) * 100
101
102    @property
103    def duration(self) -> float | None:
104        """Trade duration in days (None if open)."""
105        if not self.is_closed:
106            return None
107        assert self.exit_time is not None
108        delta = self.exit_time - self.entry_time
109        return delta.total_seconds() / 86400  # Convert to days
110
111    def to_dict(self) -> dict[str, Any]:
112        """Convert trade to dictionary."""
113        return {
114            "entry_time": self.entry_time,
115            "entry_price": self.entry_price,
116            "exit_time": self.exit_time,
117            "exit_price": self.exit_price,
118            "side": self.side,
119            "shares": self.shares,
120            "commission": self.commission,
121            "profit": self.profit,
122            "profit_pct": self.profit_pct,
123            "duration": self.duration,
124        }

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
74    @property
75    def is_closed(self) -> bool:
76        """Check if trade is closed."""
77        return self.exit_time is not None and self.exit_price is not None

Check if trade is closed.

profit: float | None
79    @property
80    def profit(self) -> float | None:
81        """Calculate profit in currency units (None if open)."""
82        if not self.is_closed:
83            return None
84        assert self.exit_price is not None
85        if self.side == "long":
86            gross = (self.exit_price - self.entry_price) * self.shares
87        else:
88            gross = (self.entry_price - self.exit_price) * self.shares
89        return gross - self.commission

Calculate profit in currency units (None if open).

profit_pct: float | None
 91    @property
 92    def profit_pct(self) -> float | None:
 93        """Calculate profit as percentage (None if open)."""
 94        if not self.is_closed or self.entry_price == 0:
 95            return None
 96        profit = self.profit
 97        if profit is None:
 98            return None
 99        entry_value = self.entry_price * self.shares
100        return (profit / entry_value) * 100

Calculate profit as percentage (None if open).

duration: float | None
102    @property
103    def duration(self) -> float | None:
104        """Trade duration in days (None if open)."""
105        if not self.is_closed:
106            return None
107        assert self.exit_time is not None
108        delta = self.exit_time - self.entry_time
109        return delta.total_seconds() / 86400  # Convert to days

Trade duration in days (None if open).

def to_dict(self) -> dict[str, typing.Any]:
111    def to_dict(self) -> dict[str, Any]:
112        """Convert trade to dictionary."""
113        return {
114            "entry_time": self.entry_time,
115            "entry_price": self.entry_price,
116            "exit_time": self.exit_time,
117            "exit_price": self.exit_price,
118            "side": self.side,
119            "shares": self.shares,
120            "commission": self.commission,
121            "profit": self.profit,
122            "profit_pct": self.profit_pct,
123            "duration": self.duration,
124        }

Convert trade to dictionary.

def backtest( symbol: str, strategy: Callable[[dict, Optional[Literal['long', 'short']], dict], Optional[Literal['BUY', 'SELL', 'HOLD']]], period: str = '1y', interval: str = '1d', capital: float = 100000.0, commission: float = 0.001, indicators: list[str] | None = None) -> BacktestResult:
816def backtest(
817    symbol: str,
818    strategy: StrategyFunc,
819    period: str = "1y",
820    interval: str = "1d",
821    capital: float = 100_000.0,
822    commission: float = 0.001,
823    indicators: list[str] | None = None,
824) -> BacktestResult:
825    """
826    Run a backtest with a single function call.
827
828    Convenience function that creates a Backtest instance and runs it.
829
830    Args:
831        symbol: Stock symbol (e.g., "THYAO").
832        strategy: Strategy function with signature:
833                  strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
834        period: Historical data period.
835        interval: Data interval.
836        capital: Initial capital.
837        commission: Commission rate.
838        indicators: List of indicators to calculate.
839
840    Returns:
841        BacktestResult with all performance metrics.
842
843    Examples:
844        >>> def rsi_strategy(candle, position, indicators):
845        ...     if indicators.get('rsi', 50) < 30 and position is None:
846        ...         return 'BUY'
847        ...     elif indicators.get('rsi', 50) > 70 and position == 'long':
848        ...         return 'SELL'
849        ...     return 'HOLD'
850
851        >>> result = bp.backtest("THYAO", rsi_strategy, period="1y")
852        >>> print(f"Net Profit: {result.net_profit_pct:.2f}%")
853        >>> print(f"Sharpe: {result.sharpe_ratio:.2f}")
854    """
855    bt = Backtest(
856        symbol=symbol,
857        strategy=strategy,
858        period=period,
859        interval=interval,
860        capital=capital,
861        commission=commission,
862        indicators=indicators,
863    )
864    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)