Quant's VLRT under the hood
I never liked Quant flex cap ever since I first heard of it.
To me the reason to buy an active fund is philosophy - ie Value, growth, quality, momentum etc.
Without this philosophy, an active fund is simply a trader in my view - and on the surface level, since Quant showcased itself as a magical crystal ball that can predict the future - my red flags were immediately waving.
But I ignored it - why look at a fund manager who you disagree with?
However, the fund kept coming back to me with people on reddit asking opinions, to it being listed as the best performer etc - and I just commented back it is not something I personally like without giving a reason.
But then Value Research hosted Sandeep Tandon - and that was the last straw that broke the camel's back.
I decided to look under the hood:
1. High Portfolio Turnover Ratio
2. Aggressive Sector Rotation based on high-beta stocks
3. Liquidity and Risk Appetite (as described by quant of VLRT model)
These characteristics essentially mimics what a momentum play does.
And so with a hypothesis that Quant's flexi cap is basically a momentum play, I benchmarked it against the Momentum Funds.
Below is the Trailing returns between Quant's flexi cap and Nifty 200 momentum 30 index, as per value research :
| Period | Quant Flexi Cap Fund | UTI Nifty 200 Momentum 30 Index fund | Motilal Oswal Nifty 500 Momentum 50 index fund |
|---|---|---|---|
| 1 Year | 19% | 5.54% | 5.78% |
| 3 Years | 17.57% | 13.49% | 12.74% |
| 5 Years | 16.39% | 11.24% | 11.88% |
But even a 5 year was not coming close, and So I had to use AI's help.
Upon discussing, It was able to map Quant Flexi cap to Nifty 500 momentum 50 TRI.
According to it, the Values would go similar to this :
| Period | Quant Flexi Cap Fund | Nifty 500 momentum 50 Tri |
|---|---|---|
| 1 Year | 18.85% | 9.33% |
| 3 Years | 18.12% | 13.85% |
| 5 Years | 16.19% | 15.02% |
Finally, we are getting somewhere, in a 5 year something closer to what Quant achieved.
But as you can see we have slight discrepancies in what the Value Research reported and what the AI was reporting even for the Quant - not much but slight variance.
And so I decided to perform a rolling return analysis using a Python script, by pulling the NAV from MFAPI - keep in mind that I am still unable to benchmark it against the TRI, but I will be able to benchmark it against the index funds to analyze how it performs.
Below are the results :
1 Year rolling return comparison between Quant, Nifty 200 momentum 30 and Nifty 500 momentum 50
Since the Nifty 500 momentum 50 is fairly new, it doesn't provide us with a long enough time frame to perform a detailed analysis - but looking at the chart, it does appear to be matching the momentum's 1 year rolling returns while providing a some downside protection.
But to get a better vantage, I removed the Nifty 500 momentum 50, so that I have a better time frame to analyze.
So comparing the 1Y rolling return between Quant and Nifty 200 momentum 30 starts to show the real picture.The same can be said about the 3Y rolling return
Again we can see that the Quant is following the same trajectory as the Momentum fund.
I also ran a 5 year rolling return as well :
Now here, you can see that Quant is able to beat the Nifty 200 momentum nicely, but keep in mind that this is only 3 months of data - it is not enough to convincingly say that is consistently generating the alpha.
And so my conclusion to the analysis is that Quant is an Active Momentum Fund, the VLRT might be a framework, but it is not operating on predictive model, it is a reactive model - its returns are purely based on the momentum play and when it detects a downfall, it hedges to stop the downward movement.
I would classify it as an Active Momentum Fund, which provides a smooth ride compared to the momentum fund.
While this analysis, did give a better impression of the fund to me, the fact that the fund manager claims predictive capabilities remains concerning.
Additionally, the exact algorithm they use is not an open-source globally back tested algorithm - but a proprietary one - and so for me, it is still too new for me to blindly trust - we don't have enough data to convincingly state the fund can outperform the index consistently.
But yes, over a 1year of a 3 year period, it appears to be holding well.
But the concerning part remains the proprietary algorithm, How exactly are they providing the downside protection remains the unanswered question.
Are they taking risky calls during the downturn to put an emergency stop to it?
While every AMCs use their proprietary algorithms, the underlying structure is clear - we are investing in the Fund manager's conviction and philosophy and not the algorithms.
But when it comes to a momentum strategy, unfortunately, it cannot claim any such human conviction - as it is mostly dictated mathematically and the philosophy is buy high and sell higher, which is a very dangerous philosophy to have.
Therefore, without the underlying algorithm, it is difficult to guarantee whether they are adopting riskier practices to stop the downfalls and is different than other active funds.
And so while the Fund itself is delivering impressive strategy, that I could potentially back - the lack of transparency of the fund manager, to not call it a momentum strategy, but a predictive strategy becomes the deciding factor for me.
It would have been a perfect fund, if the manager had claimed an active momentum strategy than a crystal ball.
I would have expected them to benchmark it appropriately and provide general hints on how he is providing the downside protection - like whether he is moving to value or using Future and option.
To me that remains a black box and don't know whether they are taking additional risks to protect the downfall - and so I must remain not invested in them.
1. Below is the code used to generate the rollng returns, feel free to check it :
import datetime
import numpy as np
import pandas as pd
import requests
import matplotlib.pyplot as plt
def fetch_mf_nav(scheme_code, name):
"""Fetches historical NAV data for an Indian Mutual Fund from mfapi.in."""
url = f'https://api.mfapi.in/mf/{scheme_code}'
response = requests.get(url)
if response.status_code != 200:
raise ValueError(f'Failed to fetch data for {name} (code: {scheme_code})')
data = response.json()
if 'data' not in data or not data['data']:
raise ValueError(f'No data returned for {name} (code: {scheme_code})')
df = pd.DataFrame(data['data'])
df['date'] = pd.to_datetime(df['date'], format='%d-%m-%Y')
df['nav'] = pd.to_numeric(df['nav'])
df = df.sort_values('date').reset_index(drop=True)
df.set_index('date', inplace=True)
return df[['nav']].rename(columns={'nav': name})
def calculate_rolling_cagr(series, years):
"""Calculates annualized rolling CAGR for a specific year window."""
window_days = int(years * 252) # Approximate trading days
return (series / series.shift(window_days)) ** (1 / years) - 1
def main():
# Setup AMFI Codes (Direct Growth variants)
FUNDS = {
'Quant Flexi Cap': '120843',
'Nifty 500 Momentum 50 (Motilal)': '152875',
'Nifty 200 Momentum 30 (UTI)': '148703'
}
# 1. Fetch all data
raw_data = {}
print("Fetching data from mfapi.in...")
for name, code in FUNDS.items():
try:
print(f" -> Fetching {name}...")
raw_data[name] = fetch_mf_nav(code, name)
except Exception as e:
print(f"Error fetching {name}: {e}")
return
windows = [1, 3, 5, 10]
# Define distinct colors for the lines
colors = {
'Quant Flexi Cap': '#8A2BE2', # Purple
'Nifty 500 Momentum 50 (Motilal)': '#FF8C00', # Orange
'Nifty 200 Momentum 30 (UTI)': '#008B8B' # Dark Cyan
}
# 2. Process each rolling window independently
for y in windows:
print(f"\n{'-'*50}\nProcessing {y}-Year Rolling Return...")
window_days = int(y * 252)
# Determine which funds actually have enough historical data for this window
qualifying_funds = {}
for name, df in raw_data.items():
if len(df) > window_days:
qualifying_funds[name] = df
else:
print(f" [Dropped] '{name}' does not have {y} years of history.")
# We need at least 2 funds to make a comparison chart
if len(qualifying_funds) < 2:
print(f" [Skipped] Not enough qualifying funds to compare for {y}-year window.")
continue
# Setup combinations to run. Default is all qualifying funds.
combinations_to_run = [("Standard", qualifying_funds)]
# ADDITION: For 1-Year, add a second run that specifically excludes Motilal for deeper history.
if y == 1 and 'Nifty 500 Momentum 50 (Motilal)' in qualifying_funds:
qualifying_without_motilal = {k: v for k, v in qualifying_funds.items() if k != 'Nifty 500 Momentum 50 (Motilal)'}
if len(qualifying_without_motilal) >= 2:
combinations_to_run.append(("Extended History", qualifying_without_motilal))
for run_type, funds_subset in combinations_to_run:
if run_type == "Extended History":
print(f"\n -> Running additional {y}-Year comparison (Excluding Nifty 500 Momentum 50 for longer history)...")
# 3. Find the youngest start date among the *subset* funds
start_dates = [df.index.min() for df in funds_subset.values()]
youngest_start = max(start_dates)
if run_type == "Standard":
print(f" -> Qualifying funds aligned to youngest start date: {youngest_start.date()}")
else:
print(f" -> Subset aligned to youngest start date: {youngest_start.date()}")
# 4. Filter, Merge, and Calculate
aligned_dfs = []
for name, df in funds_subset.items():
# Slice from the youngest start date forward
sliced_df = df[df.index >= youngest_start]
aligned_dfs.append(sliced_df)
# Join them on exact trading dates
combined_df = aligned_dfs[0]
for df in aligned_dfs[1:]:
combined_df = combined_df.join(df, how='inner')
combined_df.dropna(inplace=True)
if combined_df.empty:
print(" [Error] No overlapping data found after alignment.")
continue
# Calculate the rolling returns (%)
plot_df = pd.DataFrame(index=combined_df.index)
for name in funds_subset.keys():
plot_df[name] = calculate_rolling_cagr(combined_df[name], y) * 100
plot_df.dropna(inplace=True)
if plot_df.empty:
print(f" [Skipped] Insufficient overlap to generate {y}-year rolling data.")
continue
# 5. Generate the Chart
plt.figure(figsize=(12, 6))
for column in plot_df.columns:
plt.plot(plot_df.index, plot_df[column], label=column, color=colors[column], linewidth=1.5)
title_text = f'{y}-Year Rolling Return CAGR (%)\n(Baseline alignment: {youngest_start.date()})'
if run_type == "Extended History":
title_text += " - Excl. Nifty 500 Momentum 50"
plt.title(title_text, fontsize=14, pad=15)
plt.xlabel('Date', fontsize=12)
plt.ylabel('Rolling CAGR (%)', fontsize=12)
plt.axhline(0, color='black', linestyle='--', linewidth=0.8, alpha=0.7)
plt.legend(loc='best', fontsize=11)
plt.grid(True, alpha=0.3)
plt.tight_layout()
# Save to PNG
suffix = "_extended_history" if run_type == "Extended History" else ""
filename = f"rolling_returns_{y}Y_comparison{suffix}.png"
plt.savefig(filename, dpi=300)
plt.close()
print(f" [Success] Chart generated: {filename}")
if __name__ == '__main__':
main()
Comments
Post a Comment