| import logging |
| from typing import Dict, Any, List |
| import pandas as pd |
| import numpy as np |
| import vectorbt as vbt |
|
|
| logger = logging.getLogger("bqe.engine.friction_matrix") |
|
|
| class IndianFrictionMatrix: |
| """ |
| Implements statutory transaction fee calculations for the Indian markets (NSE/BSE). |
| |
| Supports: |
| - Brokerage |
| - Securities Transaction Tax (STT) |
| - Stamp Duty |
| - Exchange Transaction Charges |
| - SEBI Turnover Fees |
| - Goods and Services Tax (GST) |
| - Execution Slippage (Market Impact) |
| """ |
| |
| def __init__(self, slippage_pct: float = 0.05): |
| """ |
| Initializes the friction matrix. |
| |
| Args: |
| slippage_pct: Constant execution slippage rate per leg (default 0.05%). |
| """ |
| self.slippage_pct = slippage_pct / 100.0 |
|
|
| def calculate_delivery_friction(self, trade_records: pd.DataFrame) -> pd.DataFrame: |
| """ |
| Parses a DataFrame of individual trade executions and computes itemized charges. |
| |
| Statutory parameters (Equity Delivery): |
| - Brokerage: min(20, 0.0003 * trade_value) per leg |
| - STT: 0.1% of trade value on both entry and exit legs |
| - Stamp Duty: 0.015% of trade value on the entry (buy) leg only |
| - Exchange Charges: 0.00322% of trade value per leg |
| - SEBI Fee: 0.00001% of trade value per leg |
| - GST: 18% applied on (Brokerage + Exchange Charges + SEBI Fee) |
| - Slippage: Constant execution slippage per leg |
| |
| Args: |
| trade_records: DataFrame from portfolio.trades.records_readable. |
| |
| Returns: |
| pd.DataFrame: Copy of DataFrame with added itemized friction columns. |
| """ |
| if trade_records.empty: |
| logger.warning("Empty trade records provided to IndianFrictionMatrix.") |
| return trade_records.copy() |
| |
| df = trade_records.copy() |
| |
| |
| entry_price_col = next((c for c in ['Avg Entry Price', 'Entry Price'] if c in df.columns), 'Price') |
| exit_price_col = next((c for c in ['Avg Exit Price', 'Exit Price'] if c in df.columns), entry_price_col) |
| |
| size = df['Size'] |
| entry_price = df[entry_price_col] |
| exit_price = df[exit_price_col].fillna(entry_price) |
| |
| entry_value = size * entry_price |
| exit_value = size * exit_price |
| |
| |
| entry_brokerage = np.minimum(20.0, 0.0003 * entry_value) |
| entry_stt = 0.001 * entry_value |
| entry_stamp_duty = 0.00015 * entry_value |
| entry_exchange = 0.0000322 * entry_value |
| entry_sebi = 0.0000001 * entry_value |
| entry_gst = 0.18 * (entry_brokerage + entry_exchange + entry_sebi) |
| entry_slippage = self.slippage_pct * entry_value |
| entry_friction = ( |
| entry_brokerage + entry_stt + entry_stamp_duty + |
| entry_exchange + entry_sebi + entry_gst + entry_slippage |
| ) |
| |
| |
| exit_brokerage = np.minimum(20.0, 0.0003 * exit_value) |
| exit_stt = 0.001 * exit_value |
| exit_stamp_duty = 0.0 |
| exit_exchange = 0.0000322 * exit_value |
| exit_sebi = 0.0000001 * exit_value |
| exit_gst = 0.18 * (exit_brokerage + exit_exchange + exit_sebi) |
| exit_slippage = self.slippage_pct * exit_value |
| exit_friction = ( |
| exit_brokerage + exit_stt + exit_stamp_duty + |
| exit_exchange + exit_sebi + exit_gst + exit_slippage |
| ) |
| |
| |
| df['entry_brokerage'] = entry_brokerage |
| df['entry_stt'] = entry_stt |
| df['entry_stamp_duty'] = entry_stamp_duty |
| df['entry_exchange_charges'] = entry_exchange |
| df['entry_sebi_fee'] = entry_sebi |
| df['entry_gst'] = entry_gst |
| df['entry_slippage'] = entry_slippage |
| df['entry_friction'] = entry_friction |
| |
| |
| df['exit_brokerage'] = exit_brokerage |
| df['exit_stt'] = exit_stt |
| df['exit_stamp_duty'] = exit_stamp_duty |
| df['exit_exchange_charges'] = exit_exchange |
| df['exit_sebi_fee'] = exit_sebi |
| df['exit_gst'] = exit_gst |
| df['exit_slippage'] = exit_slippage |
| df['exit_friction'] = exit_friction |
| |
| |
| df['total_friction'] = entry_friction + exit_friction |
| df['net_pnl'] = df['PnL'] - df['total_friction'] |
| |
| return df |
|
|
| def apply_friction_to_portfolio( |
| self, |
| portfolio: vbt.Portfolio, |
| close_matrix: pd.DataFrame |
| ) -> Dict[str, Any]: |
| """ |
| Extracts trades, applies friction calculations, and recursively adjusts daily NAV. |
| |
| Args: |
| portfolio: The executed vectorbt Portfolio object. |
| close_matrix: The Close price matrix used in the backtest. |
| |
| Returns: |
| Dict: Net performance metrics payload. |
| """ |
| logger.info("Extracting portfolio trades for friction calculation...") |
| raw_trades_df = portfolio.trades.records_readable |
| |
| net_returns = {} |
| net_sharpes = {} |
| |
| |
| if raw_trades_df.empty: |
| logger.info("No trades occurred in portfolio. Friction adjustment bypassed.") |
| for symbol in portfolio.wrapper.columns: |
| net_returns[symbol] = float(portfolio.total_return()[symbol] * 100) |
| net_sharpes[symbol] = float(portfolio.sharpe_ratio()[symbol]) |
| return { |
| "net_total_return": { |
| "portfolio_average": sum(net_returns.values()) / len(net_returns), |
| "per_symbol": net_returns |
| }, |
| "net_sharpe_ratio": { |
| "portfolio_average": sum(net_sharpes.values()) / len(net_sharpes) if net_sharpes else 0.0, |
| "per_symbol": net_sharpes |
| }, |
| "friction_details": pd.DataFrame() |
| } |
| |
| |
| friction_trades_df = self.calculate_delivery_friction(raw_trades_df) |
| |
| col_key = next((k for k in ['Column', 'Symbol', 'column', 'symbol'] if k in friction_trades_df.columns), None) |
| entry_time_col = next((c for c in ['Entry Timestamp', 'Entry Date'] if c in friction_trades_df.columns), 'Entry Timestamp') |
| exit_time_col = next((c for c in ['Exit Timestamp', 'Exit Date'] if c in friction_trades_df.columns), 'Exit Timestamp') |
| |
| init_cash = portfolio.init_cash |
| |
| |
| for symbol in portfolio.wrapper.columns: |
| raw_val_series = portfolio.value()[symbol] |
| |
| |
| if col_key: |
| symbol_trades = friction_trades_df[friction_trades_df[col_key] == symbol] |
| else: |
| symbol_trades = pd.DataFrame() |
| |
| |
| daily_friction = pd.Series(0.0, index=close_matrix.index) |
| |
| if not symbol_trades.empty: |
| for _, trade in symbol_trades.iterrows(): |
| entry_t = trade[entry_time_col] |
| exit_t = trade[exit_time_col] |
| |
| |
| if pd.notna(entry_t): |
| entry_idx = pd.to_datetime(entry_t) |
| if entry_idx in daily_friction.index: |
| daily_friction.loc[entry_idx] += trade['entry_friction'] |
| else: |
| closest = daily_friction.index[daily_friction.index.normalize() == entry_idx.normalize()] |
| if not closest.empty: |
| daily_friction.loc[closest[0]] += trade['entry_friction'] |
| |
| |
| if pd.notna(exit_t): |
| exit_idx = pd.to_datetime(exit_t) |
| if exit_idx in daily_friction.index: |
| daily_friction.loc[exit_idx] += trade['exit_friction'] |
| else: |
| closest = daily_friction.index[daily_friction.index.normalize() == exit_idx.normalize()] |
| if not closest.empty: |
| daily_friction.loc[closest[0]] += trade['exit_friction'] |
|
|
| |
| symbol_init_cash = init_cash[symbol] if isinstance(init_cash, (pd.Series, dict)) else init_cash |
| |
| |
| net_val = symbol_init_cash |
| net_val_history = [] |
| prev_raw_val = symbol_init_cash |
| |
| for date in close_matrix.index: |
| raw_val = raw_val_series.loc[date] |
| daily_ratio = raw_val / prev_raw_val if prev_raw_val > 0.0 else 1.0 |
| |
| |
| net_val = (net_val * daily_ratio) - daily_friction.loc[date] |
| net_val = max(0.0, net_val) |
| net_val_history.append(net_val) |
| prev_raw_val = raw_val |
| |
| net_val_series = pd.Series(net_val_history, index=close_matrix.index) |
| |
| |
| net_returns[symbol] = float((net_val_series.iloc[-1] / symbol_init_cash - 1) * 100) |
| |
| |
| net_daily_returns = net_val_series.pct_change().fillna(0.0) |
| std_ret = net_daily_returns.std() |
| |
| |
| ann_factor = getattr(portfolio.wrapper, 'frequency_ann_factor', 365.0) |
| |
| net_sharpe = float((net_daily_returns.mean() / std_ret * (ann_factor ** 0.5)) if std_ret > 0.0 else 0.0) |
| net_sharpes[symbol] = net_sharpe |
| |
| portfolio_avg_return = sum(net_returns.values()) / len(net_returns) if net_returns else 0.0 |
| portfolio_avg_sharpe = sum(net_sharpes.values()) / len(net_sharpes) if net_sharpes else 0.0 |
| |
| return { |
| "net_total_return": { |
| "portfolio_average": portfolio_avg_return, |
| "per_symbol": net_returns |
| }, |
| "net_sharpe_ratio": { |
| "portfolio_average": portfolio_avg_sharpe, |
| "per_symbol": net_sharpes |
| }, |
| "friction_details": friction_trades_df |
| } |
|
|