Docs
Open Studio

Guides

Custom markets

On this page10

FinLab supports US stocks (USMarket), US ETFs and funds (USFundMarket), Taiwan stocks (TWMarket), and the Taiwan Emerging Stock Board (ROTCMarket) by default. To backtest cryptocurrencies, futures, international stocks, or your own price data, inherit the Market class and tell the backtest where its prices come from.

Different markets have different characteristics, and each one is a reason to write a custom market:

  • Different trading hours: US and Taiwan markets have different open/close times
  • Different price fields: Cryptocurrencies may not have a traditional "open price" concept
  • Different data sources: Load prices from CSV, API, or database
  • Different trading rules: Futures have leverage; cryptocurrencies have no price limits
  • Different benchmarks: US stocks benchmark against the S&P 500; Taiwan stocks against the Taiwan weighted index

Start with a built-in market

Built-in markets need no code of your own. data.set_market() selects the data and the backtest market together, or pass market= to sim():

from finlab import data, backtest
from finlab.markets.us import USMarket

# US stocks: data.set_market('us') also selects USMarket for backtests
data.set_market('us')
with data.us_universe(index='SP500'):
    close = data.get('price:adj_close')
position = close > close.average(50)
report = backtest.sim(position, resample='W')
# Equivalent to:
report = backtest.sim(position, resample='W', market=USMarket())

Taiwan stocks (data.set_market('tw'), TWMarket, the package default) and the Taiwan Emerging Stock Board (data.set_market('rotc'), ROTCMarket) work the same way.

Compare the built-in markets

FeatureUSMarketUSFundMarketTWMarketROTCMarket
Market name'us_stock''us_fund''tw_stock''rotc_stock'
Data frequencyDaily ('1D')Daily ('1D')Daily ('1D')Daily ('1D')
Benchmark indexS&P 500 price index (no dividends)S&P 500 price index (no dividends)Taiwan weighted return indexTaiwan weighted return index
TimezoneUS/EasternUS/EasternAsia/TaipeiAsia/Taipei
Market close16:0016:0015:0015:00
Default fee / tax0 / 00 / 00.1425% / 0.3%0.1425% / 0.3%
Price limitsNoneNone10%None

Write a custom market: crypto

Take daily close prices for Bitcoin (BTC) and Ethereum (ETH) in CSV files. Four steps get you to a backtest:

Prepare the price data

One CSV file per price field, such as crypto_close.csv: the first column is the date, the other columns are coin symbols.

date,BTC,ETH,BNB
2020-01-01,7200,130,15
2020-01-02,7350,135,16

Inherit Market

get_price() is the one method you must implement. It returns a price table indexed by date with one column per symbol; the other methods are optional:

from finlab.market import Market
import pandas as pd

class CryptoMarket(Market):
    @staticmethod
    def get_name():
        """Return market name"""
        return 'crypto'

    @staticmethod
    def get_freq():
        """Return data frequency"""
        return '1d'  # Daily

    def get_price(self, trade_at_price='close', adj=True):
        """Get price data

        Args:
            trade_at_price: One of 'open', 'close', 'high', 'low'
            adj: Whether to use adjusted prices (usually not needed for crypto)

        Returns:
            pd.DataFrame: index is dates, columns are coin symbols
        """
        # Load prices from CSV
        df = pd.read_csv(f'crypto_{trade_at_price}.csv', index_col=0, parse_dates=True)
        return df

    @staticmethod
    def get_benchmark():
        """Benchmark index (e.g., use BTC as benchmark)"""
        df = pd.read_csv('crypto_close.csv', index_col=0, parse_dates=True)
        return df['BTC']

Pass the market to sim()

from finlab.backtest import sim

close = pd.read_csv('crypto_close.csv', index_col=0, parse_dates=True)
position = close > close.rolling(20).mean()

report = sim(position, market=CryptoMarket(), resample='W')
report.display()

Test the market object

After building a market object, always test it:

# CryptoMarket is the CSV market from the first example; it reads crypto_close.csv

# 1. Check price data format
market = CryptoMarket()
close = market.get_price('close')
print(close.head())
print(close.index)  # Should be DatetimeIndex
print(close.shape)  # (num_dates, num_stocks)

# 2. Check benchmark index
benchmark = market.get_benchmark()
print(benchmark.head())

# 3. Run a simple backtest
position = close > close.rolling(20).mean()
report = sim(position, market=market, resample='M', upload=False)
report.display()

# 4. Check report's market name
print(report.market.get_name())  # Should be 'crypto'

Methods you can override

When inheriting the Market class, you can override the following methods. Only get_price() is required:

MethodWhat it is for
get_price(trade_at_price, adj=True)Required. Returns price data
get_name()The market name, used to identify the market type
get_freq()Data frequency, which affects return calculations
get_benchmark()The benchmark index for performance comparison
get_asset_id_to_name()A mapping from asset ID to name, used in report display
get_market_value()Market capitalization data for market-cap-weighted backtesting
get_industry()Industry classification for industry analysis
get_delisting_notices()The exchange's delisting notices
market_close_at_timestamp(timestamp)The market close time for a given timestamp, used in live trading
get_trading_price(name, adj=True)Custom execution prices; see Custom execution prices

get_name() through get_delisting_notices() are optional static methods.

get_price(): price data (required)
def get_price(self, trade_at_price='close', adj=True) -> pd.DataFrame:
    """
    Args:
        trade_at_price (str): One of 'open', 'close', 'high', 'low', 'volume'
        adj (bool): Whether to return adjusted prices (accounting for splits, dividends, etc.)

    Returns:
        pd.DataFrame:
            - index: Dates (DatetimeIndex)
            - columns: Stock/asset symbols
            - values: Prices

    Examples:
        Return format example:

        | date       |   BTC  |   ETH  |   BNB  |
        |:-----------|-------:|-------:|-------:|
        | 2020-01-01 |  7200  |   130  |   15   |
        | 2020-01-02 |  7350  |   135  |   16   |
        | 2020-01-03 |  7100  |   128  |   14.5 |
    """
    # Must implement
    raise NotImplementedError()
get_name(): market name
@staticmethod
def get_name() -> str:
    return 'crypto'  # Default is 'auto'
get_freq(): data frequency
@staticmethod
def get_freq() -> str:
    return '1d'  # Daily
    # Other common: '1h' (hourly), '4h', '1w' (weekly)
get_benchmark(): benchmark index
@staticmethod
def get_benchmark() -> pd.Series:
    """
    Returns:
        pd.Series: Benchmark index price series
            - index: Dates
            - values: Index prices
    """
    return pd.Series(
        dtype=float, index=pd.DatetimeIndex([])
    )  # Default is empty (no benchmark displayed)
get_asset_id_to_name(): asset names
@staticmethod
def get_asset_id_to_name() -> dict:
    """
    Returns:
        dict: asset_id -> asset_name mapping

    Examples:
        {'BTC': 'Bitcoin', 'ETH': 'Ethereum'}
    """
    return {}
get_market_value(): market capitalization
@staticmethod
def get_market_value() -> pd.DataFrame:
    """
    Returns:
        pd.DataFrame: Market cap data
            - index: Dates
            - columns: Stock symbols
            - values: Market cap values
    """
    return pd.DataFrame()
get_industry(): industry classification
@staticmethod
def get_industry() -> dict:
    """
    Returns:
        dict: asset_id -> industry mapping

    Examples:
        {'BTC': 'Cryptocurrency', 'ETH': 'Cryptocurrency'}
    """
    return {}
get_delisting_notices(): delisting notices

The backtest sells a holding on the first trading day after its announcement and stops buying it in later rebalances. An asset without a notice whose prices end early (at least 20 bars without a price) is sold at its last price.

@staticmethod
def get_delisting_notices() -> pd.DataFrame:
    """
    Returns:
        pd.DataFrame: delisting notices
            - index: symbol (a symbol may have several rows)
            - announced: announcement date
            - delisted: delisting date
    """
    return pd.DataFrame(
        {
            "announced": pd.Series(dtype="datetime64[ns]"),
            "delisted": pd.Series(dtype="datetime64[ns]"),
        }
    )
market_close_at_timestamp(): market close time
def market_close_at_timestamp(self, timestamp=None) -> pd.Timestamp:
    """
    Args:
        timestamp: Query time point (defaults to latest)

    Returns:
        pd.Timestamp: Most recent market close time
    """
    # Crypto trades 24 hours; use 23:59 as close
    if timestamp is None:
        timestamp = pd.Timestamp.now()
    return pd.Timestamp(timestamp.date()) + pd.Timedelta('23:59:00')

Load prices from other sources

get_price() can read prices from anywhere:

The simplest approach, suitable for one-off data:

class CSVMarket(Market):
    def __init__(self, csv_path):
        self.csv_path = csv_path

    @staticmethod
    def get_name():
        return 'csv_market'

    def get_price(self, trade_at_price='close', adj=True):
        df = pd.read_csv(self.csv_path, index_col=0, parse_dates=True)
        return df

# Usage
report = sim(position, market=CSVMarket('my_prices.csv'))

Using finlab.data.get() inside get_price() is the standard way to reuse FinLab data, and you can keep just a few symbols:

from finlab import data
from finlab.market import Market

class MegaCapMarket(Market):
    def get_price(self, trade_at_price='close', adj=True):
        # Use FinLab US stock data
        us_close = data.get('us_price:adj_close')

        # Keep specific stocks
        return us_close[['AAPL', 'MSFT', 'NVDA']]

Advanced settings

Custom execution prices

Override get_trading_price() to customize execution prices. The built-in names are 'open', 'close', 'high', 'low', 'open_close_avg', 'high_low_avg' and 'price_avg'; this example adds the typical price, a common stand-in for VWAP:

from finlab.backtest import sim
from finlab.markets.us import USMarket

class TypicalPriceMarket(USMarket):
    def get_trading_price(self, name, adj=True):
        """Custom execution price calculation"""
        if name == 'typical':  # (high + low + close) / 3
            high = self.get_price('high', adj=adj)
            low = self.get_price('low', adj=adj)
            close = self.get_price('close', adj=adj)
            return (high + low + close) / 3
        # Use the default logic for the built-in names
        return super().get_trading_price(name, adj=adj)

# Execute the S&P 500 strategy from the Quick Start at the typical price
report = sim(
    position, resample='M', market=TypicalPriceMarket(), trade_at_price='typical'
)

Markets across time zones

Handle cross-timezone markets with market_close_at_timestamp():

import pandas as pd
from finlab.market import Market

class MultiTimezoneMarket(Market):
    @staticmethod
    def get_name():
        return 'multi_tz'

    def market_close_at_timestamp(self, timestamp=None):
        """Different markets have different close times"""
        if timestamp is None:
            timestamp = pd.Timestamp.now(tz='UTC')

        # For a global 24-hour market, use UTC
        timestamp_utc = timestamp.tz_convert('UTC')
        market_close = pd.Timestamp(timestamp_utc.date()) + pd.Timedelta('23:59:59')
        return market_close.tz_localize('UTC')

print(MultiTimezoneMarket().market_close_at_timestamp())  # Today 23:59:59 UTC

Exclude weekends and holidays

import pandas as pd
from finlab.market import Market

class WithHolidaysMarket(Market):
    def __init__(self):
        # Define holidays (e.g., NYSE holidays)
        self.holidays = ['2024-01-01', '2024-01-15', '2024-02-19']
        self.holidays = [pd.Timestamp(d) for d in self.holidays]

    def get_price(self, trade_at_price='close', adj=True):
        df = pd.read_csv('prices.csv', index_col=0, parse_dates=True)

        # Remove weekends
        df = df[df.index.dayofweek < 5]

        # Remove holidays
        df = df[~df.index.isin(self.holidays)]

        return df

Simulate futures leverage through prices

Futures have leverage, which can be simulated by modifying prices:

class FuturesMarket(Market):
    def __init__(self, leverage=10):
        """
        Args:
            leverage: Leverage multiplier
        """
        self.leverage = leverage
        self.prices = pd.read_csv('futures_price.csv', index_col=0, parse_dates=True)

    @staticmethod
    def get_name():
        return 'futures'

    @staticmethod
    def get_freq():
        return '1d'

    def get_price(self, trade_at_price='close', adj=True):
        """Return leveraged returns"""
        price = self.prices[trade_at_price]

        # Calculate daily returns multiplied by leverage
        daily_return = price.pct_change() * self.leverage

        # Convert back to price (cumulative from 100)
        leveraged_price = (1 + daily_return).fillna(1).cumprod() * 100

        return leveraged_price

    @staticmethod
    def get_benchmark():
        # Use 1x leverage as benchmark
        df = pd.read_csv('futures_price.csv', index_col=0, parse_dates=True)
        return df['close']

# Backtest with 10x leverage
report = sim(position, market=FuturesMarket(leverage=10))

Practical examples

Gold ETF

class GoldMarket(Market):
    @staticmethod
    def get_name():
        return 'gold'

    @staticmethod
    def get_freq():
        return '1d'

    def get_price(self, trade_at_price='close', adj=True):
        # Load Gold ETF (GLD) prices from Yahoo Finance
        import yfinance as yf

        gold = yf.download('GLD', start='2010-01-01')
        return gold[[trade_at_price.capitalize()]]

    @staticmethod
    def get_benchmark():
        import yfinance as yf

        sp500 = yf.download('^GSPC', start='2010-01-01')
        return sp500['Close'].squeeze()

# Usage
gold_close = pd.read_csv('gold_close.csv', index_col=0, parse_dates=True)
position = gold_close > gold_close.rolling(50).mean()
report = sim(position, market=GoldMarket(), resample='W')

US stocks and ETFs in one backtest

class StocksAndEtfsMarket(Market):
    @staticmethod
    def get_name():
        return 'us_stocks_etfs'

    def get_price(self, trade_at_price='close', adj=True):
        # US stocks
        stocks = data.get('us_price:adj_close')[['AAPL', 'MSFT']]

        # US ETFs
        etfs = data.get('us_fund_price:adj_close')[['SPY', 'TLT']]

        # Merge (outer join, auto-aligns dates)
        return pd.concat([stocks, etfs], axis=1)

    @staticmethod
    def get_benchmark():
        # S&P 500 index
        return data.get('world_index:adj_close')['^GSPC']

# Usage
position = ...  # Position including the stocks and ETFs above
report = sim(position, market=StocksAndEtfsMarket())

Crypto on 4-hour candles

get_freq() returns '4h', and the resample parameter should match the frequency:

class Crypto4HMarket(Market):
    @staticmethod
    def get_name():
        return 'crypto_4h'

    @staticmethod
    def get_freq():
        return '4h'  # 4-hour frequency

    def get_price(self, trade_at_price='close', adj=True):
        # Load 4-hour candle data from Binance API
        df = pd.read_csv('binance_4h_close.csv', index_col=0, parse_dates=True)
        return df

# Usage
close = pd.read_csv('binance_4h_close.csv', index_col=0, parse_dates=True)
position = close > close.rolling(50).mean()

# Note: resample parameter should match the frequency
report = sim(position, market=Crypto4HMarket(), resample='1d')  # Daily rebalancing

Taiwan single-stock futures by contract lots

TWStockFuturesMarket is stage one of single-stock futures backtesting. It simulates Taiwan single-stock futures and does not apply to US stocks. It uses an independent Python/NumPy engine with NTD equity and integer contracts: 2,000 shares for contract='standard', 100 for 'mini'. Columns remain stock IDs. The caller must select eligible stocks; historical futures eligibility is not checked.

import pandas as pd
from finlab import backtest
from finlab.markets.tw_stock_futures import TWStockFuturesMarket

dates = pd.bdate_range('2024-01-01', periods=5)
symbol = 'STOCK_A'  # A Taiwan stock ID with single-stock futures
prices = pd.DataFrame({symbol: [100.0, 100.0, 105.0, 110.0, 110.0]}, index=dates)
position = pd.DataFrame({symbol: [1.0, 1.0, 1.0, 0.0, 0.0]}, index=dates)
market = TWStockFuturesMarket(
    contract='standard',
    initial_capital=250_000,
    leverage=1,
    fee_per_lot=20,  # Example only: replace with your broker's commission per side.
    tax_ratio=2e-5,
    margin_ratio=0.135,  # Assumption; a stock-ID mapping is also accepted.
)
report = backtest.sim(position, market=market, trade_at_price=prices, upload=False)
assert abs(report.account.equity.iloc[-1] - 269_951.6) < 1e-8
print(report.account)
print(report.transactions)
report.display()  # Local summary, or report.to_html('futures.html').

Omit trade_at_price to read adjusted underlying closes (etl:adj_close). Use resample='M' for monthly rebalancing and leverage=2 on the market for twice-equity target exposure; do not also multiply positions by leverage. Stage one accepts eager pandas/FinlabDataFrame positions, daily adjusted closes or a custom daily price DataFrame, and a frequency string or resample=None.

You can also select this market object globally with from finlab import config; config.set_market(market). Omitting sim(market=...) then uses the same futures lot engine; an explicit market takes precedence. config.reset_market() restores the default stock market. This backtest setting is separate from the dataset setting data.set_market(...).

Sizing, fills and accounting rules

  • Weights whose absolute sum exceeds one are normalized to one; sums below one preserve unallocated capital. position_limit then caps each weight. Negative weights are shorts.
  • Target notional = pre-rebalance equity × leverage × weight; lots are truncated toward zero after division by price × shares per contract.
  • Signals execute strictly on the next available price trading day. Signal and price indexes are normalized to midnight in their shared timezone: daily 13:30 close labels still mean a T signal executes on T+1, and multiple rows on one day are rejected.
  • With resample=None, only position changes trigger adjustments; a string frequency samples the latest known signal on scheduled calendar dates, then executes on the next trading day.
  • Lots remain fixed between adjustments: daily P&L = held lots × multiplier × price change. Margin occupies equity without subtracting it from equity, and contract notional is not a cash purchase.
  • Commission is absolute executed lots × fee_per_lot; tax is absolute executed lots × multiplier × price × tax_ratio, on both entry and exit (including reductions and reversals). Omitting fee_per_lot raises an explanatory error: there is no default broker rate. The default tax of 2e-5 follows Taiwan's futures transaction tax rate for stock futures; the TAIFEX fee schedule states that broker charges are negotiated. Amounts retain fractional NTD; broker-specific rounding is not simulated.
  • Insufficient margin triggers proportional scaling and integer rounding, reserving fees and tax, with FuturesSimulationWarning. The reduction uses a conservative upper bound on turnover costs and leaves rounding cash unallocated, so it can keep more cash than a maximal-lot optimizer. A target below one lot becomes zero with a warning.
  • In the example, one lot bought at 100 costs 20 commission + 4 tax; selling at 110 costs 20 + 4.4. Gross P&L is 20,000 and net P&L is 19,951.6 NTD.
  • end_date is inclusive; open positions are marked, not liquidated at the endpoint. Dates must be sorted and unique, symbols unique, and selected prices positive without missing values. Missing prices raise instead of fabricating valuations or execution prices.

What the report contains

The returned FuturesReport subclasses Report, reusing get_stats(), return-derived report.metrics, and cumulative returns. Its get_metrics() returns available statistics, contract assumptions and a list of unavailable analyses. Without a supplied benchmark, the reference is cash. display() and to_html() render a local equity curve and ledgers, rather than requiring stock dashboard fields.

FieldMeaning
creturn, daily_creturnEquity / initial capital, including costs; a pre-execution cash observation is included for a first holiday signal
accountDaily NTD equity, P&L, commission, tax, margin, free equity, gross notional and margin_excess
lot_positionsActual signed integer lots per stock per day
transactionsExecutions, signal dates, signed lots, prices, notional and costs
tradesFIFO closed and marked open lots; open exit_date is NaT, and return is net P&L / entry notional, not a margin return
weights, positionActual signed exposure / equity; weights may exceed one and become NaN for nonzero holdings if equity is nonpositive
When equity falls to zero or below

If equity ever reaches zero or below, percentage return statistics for the entire path are invalid, even if equity later recovers. get_stats(), ratio metrics such as Sharpe/CAGR/risk on report.metrics or metrics_only=True, and related analyses raise ValueError with the first date. get_metrics() and to_json() instead export empty stats, performance.valid=False, the first date and reason, and percentage_return_statistics in the unavailable list; HTML also displays the reason. The complete NTD and lot ledgers continue: the report does not hide losses by truncating the period or invent a margin-call/liquidation model. FIFO P&L and return on entry notional remain available for reconciliation.

Export HTML and JSON

to_html(path, title=None) preserves the Report calling contract, including path=... and title=.... The local to_json() summary exports trade dates as ISO strings, missing open-trade exit dates as null, and non-finite statistics as null. It supports the existing JSON serializer and standard json.dumps(..., allow_nan=False), while retaining its stage-one summary format rather than the full stock dashboard/cloud payload.

What is not supported

MAE/MFE, stock liquidity, cloud uploads and live stock execution are unavailable; the corresponding analysis and execution APIs explicitly reject use, without fabricated columns. The public ReportExecution.from_report(report)/constructor and Position.from_report(report, ...) reject before price reads, preventing CASH stock positions. The market's stock trading-time and board-lot APIs also reject; read contract_multiplier for contract size. Nondefault stop-loss, take-profit, trailing-stop/activation, touched-exit, MAE/MFE windows, retained cost, notifications and other unsupported settings raise errors. Set commission and tax on the market: sim(fee_ratio=...) and sim(tax_ratio=...) are rejected.

Later stages will add expiry calendars and roll costs/spread assumptions, actual near/far-month prices, historical per-stock margins, contract adjustments and eligibility data.

FAQ

What does adj=True mean?

adj=True means using adjusted prices (accounting for stock splits, dividends, etc.):

  • US stocks: Historical prices are adjusted for splits and dividends to ensure correct return calculations
  • Taiwan stocks: Also adjusted after ex-rights/ex-dividends
  • Cryptocurrency: No splits or dividends; the adj parameter can be ignored
from finlab.markets.us import USMarket

us_market = USMarket()
adj_close = us_market.get_price(
    'close', adj=True
)  # Adjusted price (us_price:adj_close)
raw_close = us_market.get_price(
    'close', adj=False
)  # Raw closing price (us_price:close)

How do I handle missing values (NaN)?

FinLab automatically forward-fills missing values, but it is recommended to handle them in get_price():

def get_price(self, trade_at_price='close', adj=True):
    df = pd.read_csv('prices.csv', index_col=0, parse_dates=True)

    # Method 1: Forward fill
    df = df.ffill()

    # Method 2: Remove stocks with missing values
    df = df.dropna(axis=1, how='any')

    # Method 3: Fill with 0 (not recommended)
    df = df.fillna(0)

    return df

How do I set fees and taxes?

Transaction costs are set in the sim() function, not in the Market object:

from finlab.backtest import sim
from finlab.markets.us import USMarket

# A broker that charges 0.05% commission per trade; U.S. stocks have no transaction tax
report = sim(
    position,
    resample='M',
    market=USMarket(),
    fee_ratio=0.0005,
    tax_ratio=0,
)

# Built-in defaults: USMarket fee_ratio=0, tax_ratio=0; TWMarket fee_ratio=0.001425, tax_ratio=0.003

The exception is TWStockFuturesMarket, whose commission and tax are set on the market object (see above).

Why is get_benchmark() a static method?

Because the benchmark index is usually fixed and does not depend on instance variables. If you need a benchmark that changes with a parameter, make it an instance method:

class DynamicBenchmarkMarket(Market):
    def __init__(self, benchmark_ticker):
        self.benchmark_ticker = benchmark_ticker

    def get_benchmark(self):  # Changed to instance method (removed @staticmethod)
        df = pd.read_csv('benchmarks.csv', index_col=0, parse_dates=True)
        return df[self.benchmark_ticker]

# Usage
report = sim(position, market=DynamicBenchmarkMarket('BTC'))

Common errors and solutions

Error 1: File read failure

Symptom: Custom market raises FileNotFoundError when executed.

class CryptoMarket(Market):
    def get_price(self, trade_at_price='close', adj=True):
        df = pd.read_csv('crypto_close.csv', index_col=0, parse_dates=True)
        return df

market = CryptoMarket()
close = market.get_price('close')
# FileNotFoundError: [Errno 2] No such file or directory: 'crypto_close.csv'

Causes:

  • CSV file does not exist or the path is wrong
  • Using a relative path while the working directory is incorrect
  • Filename is misspelled or the case does not match

Solution: let the market take a data directory, check the file before reading it, and validate the format afterwards.

CryptoMarket with error handling
from finlab.market import Market
import pandas as pd
import os

class CryptoMarket(Market):
    def __init__(self, data_dir='./data'):
        """
        Args:
            data_dir: Data directory path (default: ./data)
        """
        self.data_dir = data_dir

    @staticmethod
    def get_name():
        return 'crypto'

    @staticmethod
    def get_freq():
        return '1d'

    def get_price(self, trade_at_price='close', adj=True):
        """Fetch price data (with error handling)."""

        # Build the full path
        file_path = os.path.join(self.data_dir, f'crypto_{trade_at_price}.csv')

        # Check whether the file exists
        if not os.path.exists(file_path):
            raise FileNotFoundError(
                f"Data file not found: {file_path}\n"
                f"   Please verify:\n"
                f"   1. The file exists in the {self.data_dir} directory\n"
                f"   2. The filename is correct (crypto_{trade_at_price}.csv)\n"
                f"   3. The working directory is correct (current: {os.getcwd()})\n"
                f"   Tip: sample data can be downloaded from https://example.com/data"
            )

        # Try to read the file
        try:
            df = pd.read_csv(file_path, index_col=0, parse_dates=True)

        except PermissionError:
            raise PermissionError(
                f"No permission to read file: {file_path}\n"
                f"   Please check the file permission settings"
            )

        except pd.errors.EmptyDataError:
            raise ValueError(
                f"Data file is empty: {file_path}\n   Please verify the file contents"
            )

        except Exception as e:
            raise RuntimeError(
                f"Failed to read file: {file_path}\n   Error message: {e}"
            )

        # Validate data integrity (next step, see Error 2)
        df = self._validate_dataframe(df, file_path)

        return df

    def _validate_dataframe(self, df, file_path):
        """Validate DataFrame format."""

        # Check whether it is empty
        if df.empty:
            raise ValueError(
                f"Data is empty: {file_path}\n   Please verify the file contents"
            )

        # Check whether the index is a DatetimeIndex
        if not isinstance(df.index, pd.DatetimeIndex):
            raise TypeError(
                f"Index must be a DatetimeIndex (date format)\n"
                f"   Current index type: {type(df.index)}\n"
                f"   Please ensure the first CSV column is a date (e.g. 2020-01-01)\n"
                f"   and use the parse_dates=True argument"
            )

        # Check whether there is any numeric data
        if df.shape[1] == 0:
            raise ValueError(
                f"No stock columns found: {file_path}\n"
                f"   Please ensure the CSV format is correct (first column dates, other columns ticker symbols)"
            )

        print(f"Data loaded successfully: {file_path}")
        print(f"   Date range: {df.index[0]} ~ {df.index[-1]}")
        print(f"   Number of stocks: {df.shape[1]}")
        print(f"   Number of trading days: {df.shape[0]}")

        return df

    @staticmethod
    def get_benchmark():
        """Benchmark index (with error handling)."""
        try:
            df = pd.read_csv('crypto_close.csv', index_col=0, parse_dates=True)
            if 'BTC' not in df.columns:
                print("Warning: BTC column not found, benchmark index cannot be set")
                return pd.Series(
                    dtype=float, index=pd.DatetimeIndex([])
                )  # Return an empty Series
            return df['BTC']
        except Exception as e:
            print(f"Warning: failed to load benchmark index: {e}")
            return pd.Series(
                []
            )  # Return an empty Series so the main workflow is unaffected

# Usage: specify the data directory
try:
    market = CryptoMarket(data_dir='/path/to/crypto/data')
    close = market.get_price('close')
    print("Market object created successfully")

except FileNotFoundError as e:
    print(f"\n{e}")
    print("\nPlease download sample data or fix the file path")

except Exception as e:
    print(f"\nFailed to create market object: {e}")

Error 2: Invalid data format

Symptom: DataFrame format does not meet expectations, causing the backtest to fail. A common case is dates read as plain strings (a CSV loaded without parse_dates=True).

Common format issues:

  1. Index is not a DatetimeIndex
  2. Required columns are missing
  3. Incorrect data types (strings instead of numbers)
  4. Too many missing values

Solution: strengthen validation inside get_price(): convert dates and numbers, handle missing values, sort and drop duplicate dates.

Reproduce the string-date error
import numpy as np
import pandas as pd
from finlab.backtest import sim
from finlab.market import Market

dates = pd.bdate_range('2024-01-01', periods=60)
prices = pd.DataFrame(
    {'BTC': np.linspace(100, 160, 60), 'ETH': np.linspace(50, 40, 60)}, index=dates
)

class StringDateMarket(Market):
    def get_price(self, trade_at_price='close', adj=True):
        df = prices.copy()
        df.index = df.index.strftime('%Y-%m-%d')  # dates as strings
        return df

position = prices > prices.rolling(5).mean()
try:
    sim(position, market=StringDateMarket(), upload=False)
except TypeError as e:
    print(e)  # operation 'sub' not supported for dtype 'str' with dtype 'str'
get_price() with full validation
class RobustMarket(Market):
    def get_price(self, trade_at_price='close', adj=True):
        """Fetch price data (with full validation)."""

        df = pd.read_csv(f'data_{trade_at_price}.csv', index_col=0)

        # Validation 1: convert index to DatetimeIndex
        if not isinstance(df.index, pd.DatetimeIndex):
            try:
                df.index = pd.to_datetime(df.index)
                print("Index converted to DatetimeIndex")
            except Exception as e:
                raise TypeError(
                    f"Cannot convert index to DatetimeIndex\n"
                    f"   Index sample: {df.index[:3].tolist()}\n"
                    f"   Error message: {e}\n"
                    f"   Please ensure the first column is a date (e.g. 2020-01-01 or 2020/01/01)"
                )

        # Validation 2: check data types
        non_numeric_cols = df.select_dtypes(exclude=['number']).columns.tolist()
        if non_numeric_cols:
            print(
                f"Warning: the following columns are non-numeric and will be converted: {non_numeric_cols}"
            )
            for col in non_numeric_cols:
                try:
                    df[col] = pd.to_numeric(df[col], errors='coerce')
                except Exception as e:
                    raise TypeError(
                        f"Cannot convert column {col} to numeric\n"
                        f"   Please ensure the column contains numbers"
                    )

        # Validation 3: check missing-value ratio
        missing_ratio = df.isna().sum() / len(df)
        high_missing_cols = missing_ratio[missing_ratio > 0.3].index.tolist()

        if high_missing_cols:
            print(f"Warning: the following columns have > 30% missing values:")
            for col in high_missing_cols:
                print(f"     {col}: {missing_ratio[col]:.1%} missing")
            print("   Suggestions:")
            print("   1. Drop these columns (df.drop(columns=high_missing_cols))")
            print("   2. Forward-fill the gaps (df.ffill())")

            # Auto-handle: forward fill
            df = df.ffill()
            print("   Missing values have been forward-filled automatically")

        # Validation 4: check data size
        if len(df) < 100:
            print(f"Warning: too few data points ({len(df)} rows)")
            print("   At least 252 trading days (about 1 year) is recommended")

        if df.shape[1] < 2:
            print(f"Warning: too few stocks ({df.shape[1]})")
            print("   At least 10 stocks are recommended for diversification")

        # Validation 5: sort the index
        if not df.index.is_monotonic_increasing:
            print("Warning: dates are unsorted, sorting automatically")
            df = df.sort_index()

        # Validation 6: remove duplicate dates
        if df.index.duplicated().any():
            dup_dates = df.index[df.index.duplicated()].tolist()
            print(f"Warning: duplicate dates found: {dup_dates[:5]}...")
            df = df[~df.index.duplicated(keep='last')]
            print("   Keeping the last record only")

        print(f"\nData validation complete")
        print(f"   Date range: {df.index[0].date()} ~ {df.index[-1].date()}")
        print(f"   Trading days: {len(df)}")
        print(f"   Number of stocks: {df.shape[1]}")
        print(
            f"   Missing values: {df.isna().sum().sum()} ({df.isna().sum().sum() / df.size:.2%})"
        )

        return df

# Usage
try:
    market = RobustMarket()
    close = market.get_price('close')

    # Run backtest
    position = close > close.rolling(20).mean()
    report = sim(position, market=market, resample='M')
    print("\nBacktest succeeded")

except (TypeError, ValueError) as e:
    print(f"\n{e}")
    print("\nPlease fix the data format and retry")

Error 3: API request failure

Symptom: Loading data via an API raises a network error.

class APIMarket(Market):
    def get_price(self, trade_at_price='close', adj=True):
        response = requests.get(f'https://api.example.com/{trade_at_price}')
        return pd.DataFrame(response.json())

market = APIMarket()
close = market.get_price('close')
# requests.exceptions.ConnectionError: Failed to establish a new connection

Causes:

  • Network connectivity issues
  • API key is invalid or expired
  • API rate limit exceeded
  • API returned an unexpected format

Solution: add retry logic and error handling, and read the API key from an environment variable.

APIMarket with retries and error handling
import requests
import time
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry

class APIMarket(Market):
    def __init__(self, api_url, api_key=None, max_retries=3):
        """
        Args:
            api_url: API base URL
            api_key: API key (optional)
            max_retries: Maximum number of retries
        """
        self.api_url = api_url
        self.api_key = api_key
        self.max_retries = max_retries

        # Build a Session with retry logic
        self.session = self._create_session()

    def _create_session(self):
        """Create a requests Session with retry logic."""
        session = requests.Session()

        # Configure retry strategy
        retry_strategy = Retry(
            total=self.max_retries,
            backoff_factor=1,  # Exponential backoff: 1s, 2s, 4s
            status_forcelist=[429, 500, 502, 503, 504],  # HTTP status codes to retry on
            allowed_methods=["HEAD", "GET", "OPTIONS"],
        )

        adapter = HTTPAdapter(max_retries=retry_strategy)
        session.mount("http://", adapter)
        session.mount("https://", adapter)

        return session

    @staticmethod
    def get_name():
        return 'api_market'

    def get_price(self, trade_at_price='close', adj=True):
        """Fetch price data from an API (with error handling)."""

        url = f'{self.api_url}/prices/{trade_at_price}'

        # Set request headers
        headers = {}
        if self.api_key:
            headers['Authorization'] = f'Bearer {self.api_key}'

        print(f"Downloading data from API: {url}")

        try:
            # Send the request (with a timeout)
            response = self.session.get(
                url,
                headers=headers,
                timeout=30,  # 30-second timeout
            )

            # Check the HTTP status code
            if response.status_code == 401:
                raise PermissionError(
                    f"API authentication failed (HTTP 401)\n"
                    f"   Please verify the API key is correct and not expired\n"
                    f"   Re-issue a key from the API provider if needed"
                )

            elif response.status_code == 429:
                raise RuntimeError(
                    f"API rate limit exceeded (HTTP 429)\n"
                    f"   Please retry later or upgrade your API plan"
                )

            elif response.status_code >= 500:
                raise RuntimeError(
                    f"API server error (HTTP {response.status_code})\n"
                    f"   The server is temporarily unavailable, please retry later"
                )

            response.raise_for_status()  # Raise any other HTTP errors

            # Parse JSON
            data = response.json()

        except requests.exceptions.Timeout:
            raise TimeoutError(
                f"API request timed out (> 30 seconds)\n"
                f"   Network is too slow or the API server is overloaded\n"
                f"   Suggestions:\n"
                f"   1. Check network connectivity\n"
                f"   2. Reduce the data range\n"
                f"   3. Retry later"
            )

        except requests.exceptions.ConnectionError:
            raise ConnectionError(
                f"Network connection failed\n"
                f"   Unable to connect to {self.api_url}\n"
                f"   Please check:\n"
                f"   1. Network connectivity\n"
                f"   2. API URL is correct\n"
                f"   3. Firewall is not blocking the connection"
            )

        except requests.exceptions.JSONDecodeError:
            raise ValueError(
                f"API returned an invalid format\n"
                f"   Expected JSON, but received: {response.text[:200]}...\n"
                f"   Please contact the API provider to confirm the documentation"
            )

        except Exception as e:
            raise RuntimeError(
                f"API request failed: {e}\n   Please inspect the detailed error message"
            )

        # Convert to DataFrame
        try:
            df = pd.DataFrame(data)

            # Handle common formats
            if 'date' in df.columns:
                df['date'] = pd.to_datetime(df['date'])
                df = df.set_index('date')
            elif 'timestamp' in df.columns:
                df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s')
                df = df.set_index('timestamp')

            print(f"API data downloaded successfully")
            print(f"   Date range: {df.index[0]} ~ {df.index[-1]}")
            print(f"   Number of stocks: {df.shape[1]}")

            return df

        except Exception as e:
            raise ValueError(
                f"Data conversion failed: {e}\n"
                f"   API response: {data}\n"
                f"   Please verify the data format documented by the API"
            )

# Usage
import os

try:
    # Read the API key from an environment variable (recommended)
    api_key = os.environ.get('CRYPTO_API_KEY')
    if not api_key:
        print("Warning: CRYPTO_API_KEY environment variable is not set")
        print("   export CRYPTO_API_KEY='your_api_key'")

    market = APIMarket(
        api_url='https://api.example.com', api_key=api_key, max_retries=3
    )

    close = market.get_price('close')
    print("Market data fetched successfully")

except (ConnectionError, TimeoutError, PermissionError) as e:
    print(f"\n{e}")
    print("\nPlease fix the issue and retry")

except Exception as e:
    print(f"\nUnexpected error: {e}")

Error 4: Backtest execution failure

Symptom: Market object is created successfully, but the backtest raises an error. Here the position uses different symbols than the market's prices (prices from the Error 2 example):

class PriceMarket(Market):
    def get_price(self, trade_at_price='close', adj=True):
        return prices  # columns: BTC, ETH

signals = prices > prices.rolling(5).mean()
position = signals.rename(columns={'BTC': 'BTC-USD', 'ETH': 'ETH-USD'})
try:
    sim(position, market=PriceMarket(), upload=False)
except ValueError as e:
    print(
        e
    )  # No position columns match the market price columns (0 out of 2 matched) ...

Causes:

  • position and the price data have mismatched ticker symbols (the error above)
  • position dates fall outside the price dates: there is no error, but nothing is traded
  • Market object methods are not implemented correctly

Solution: validate dates, symbols and methods before running the backtest.

Pre-backtest compatibility check
def validate_before_backtest(position, market):
    """Validate compatibility between position and market before backtesting."""

    print("=== Pre-backtest validation ===\n")

    # 1. Fetch market prices
    try:
        close = market.get_price('close')
    except Exception as e:
        raise RuntimeError(f"Unable to fetch market prices: {e}")

    # 2. Check date ranges
    position_dates = position.index
    price_dates = close.index

    print(f"Position date range: {position_dates[0]} ~ {position_dates[-1]}")
    print(f"Price date range: {price_dates[0]} ~ {price_dates[-1]}")

    # Intersection
    common_dates = position_dates.intersection(price_dates)
    if len(common_dates) == 0:
        raise ValueError(
            f"Position and Price share no overlapping dates\n"
            f"   Please ensure the data sources are consistent"
        )

    if len(common_dates) < len(position_dates) * 0.8:
        print(
            f"Warning: only {len(common_dates) / len(position_dates):.1%} of position dates have matching prices"
        )
        print("   Some trading days may be ignored")

    # 3. Check ticker symbols
    position_stocks = position.columns.tolist()
    price_stocks = close.columns.tolist()

    print(f"\nPosition stock count: {len(position_stocks)}")
    print(f"Price stock count: {len(price_stocks)}")

    # Intersection
    common_stocks = set(position_stocks).intersection(set(price_stocks))
    if len(common_stocks) == 0:
        raise ValueError(
            f"Position and Price share no overlapping tickers\n"
            f"   Position sample: {position_stocks[:5]}\n"
            f"   Price sample: {price_stocks[:5]}\n"
            f"   Please ensure the ticker symbol formats match"
        )

    missing_stocks = set(position_stocks) - set(price_stocks)
    if missing_stocks:
        print(f"Warning: {len(missing_stocks)} tickers are not present in Price:")
        print(f"   {list(missing_stocks)[:10]}...")
        print("   These tickers will be ignored")

    # 4. Check market object methods
    required_methods = ['get_name', 'get_freq', 'get_price']
    for method in required_methods:
        if not hasattr(market, method):
            raise AttributeError(
                f"Market object is missing a required method: {method}\n"
                f"   Please ensure the Market class is subclassed and all methods are implemented"
            )

    print(
        f"\nValidation passed: {len(common_stocks)} stocks, {len(common_dates)} trading days"
    )
    print("=" * 50)

# Usage: check a U.S. strategy against USMarket (or your own market object)
from finlab.markets.us import USMarket

try:
    market = USMarket()
    data.set_market('us')
    with data.us_universe(index='SP500'):
        close = data.get('price:adj_close')
    position = close > close.rolling(20).mean()

    # Run validation
    validate_before_backtest(position, market)

    # Run backtest
    report = sim(position, market=market, resample='M', upload=False)
    print("\nBacktest succeeded")
    report.display()

except Exception as e:
    print(f"\nBacktest failed: {e}")
    print("Please fix the issue and retry")

Debugging tips

Test the market object step by step
# CryptoMarket is the CSV market from the first example; it reads crypto_close.csv

# Step 1: Test basic methods
market = CryptoMarket()
print(f"Market name: {market.get_name()}")
print(f"Data frequency: {market.get_freq()}")

# Step 2: Test price loading
close = market.get_price('close')
print(f"\nClose price data:\n{close.head()}")

# Step 3: Test benchmark index
benchmark = market.get_benchmark()
print(f"\nBenchmark index:\n{benchmark.head()}")

# Step 4: Run simple strategy
position = close > close.rolling(20).mean()
print(f"\nPosition signals:\n{position.tail()}")

# Step 5: Small-scale backtest
position_small = position.iloc[-100:]  # Only last 100 days
report = sim(position_small, market=market, resample='M', upload=False)
report.display()
Wrap key steps with try-except
class SafeMarket(Market):
    def get_price(self, trade_at_price='close', adj=True):
        try:
            # Main logic
            df = pd.read_csv(
                f'data_{trade_at_price}.csv', index_col=0, parse_dates=True
            )
            return df

        except FileNotFoundError as e:
            print(f"File not found: {e}")
            raise

        except Exception as e:
            print(f"Unexpected error: {e}")
            print(f"   Error type: {type(e).__name__}")
            raise
Log the details
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class LoggedMarket(Market):
    def get_price(self, trade_at_price='close', adj=True):
        logger.info(f"Loading price data: {trade_at_price}")

        try:
            df = pd.read_csv(
                f'data_{trade_at_price}.csv', index_col=0, parse_dates=True
            )
            logger.info(f"Data loaded successfully: {df.shape}")
            return df

        except Exception as e:
            logger.error(f"Data loading failed: {e}", exc_info=True)
            raise

Next steps

To see how the built-in markets are written, read the USMarket source code and the TWMarket source code.

↑↓ Move Enter Open Esc Close