import yfinance as yf # For downloading financial dataimport numpy as np # For numerical operationsimport pandas as pd # For data manipulationimport requests # For downloading the API dataimport numpy as np import plotly.graph_objects as goimport plotly.express as px # Import the Plotly Express module for interactive visualizationimport jsonimport vectorbt as vbtfrom plotly.subplots import make_subplotsimport streamlit as stimport plotly.io as piopio.renderers.default ='iframe_connected'
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Data Collection
Fetch daily OHLCV data
Code
# Data for the TSLA, XLY, and SPY tickers is retrieved from the Yahoo Finance library, covering the period from January 1, 2019, # to March 5, 2025.tsla = yf.download('TSLA', start='2019-01-01', end='2025-03-05') xly = yf.download('XLY', start='2019-01-01', end='2025-03-05')spy = yf.download('SPY', start='2019-01-01', end='2025-03-05')
YF.download() has changed argument auto_adjust default to True
[*********************100%***********************] 1 of 1 completed
[*********************100%***********************] 1 of 1 completed
[*********************100%***********************] 1 of 1 completed
Code
def multiindex_to_singleindex(df): df.columns = ['_'.join(col).strip() for col in df.columns.values]return df
# Defines a function to calculate the Vortex Indicator (VI) for a given DataFrame and ticker symbol.# The calculation uses a default lookback period of 14 days unless specified otherwise.def calculate_vortex(df, value, n=14):# Extracts the high, low, and close price series for the specified ticker. high = df[("High_"+value)] low = df[("Low_"+value)] close = df[("Close_"+value)]# Calculates the Vortex Movement values:# VM+ = absolute difference between today's high and yesterday's low# VM− = absolute difference between today's low and yesterday's high vm_plus =abs(high - low.shift(1)) # |Today's High – Yesterday’s Low| vm_minus =abs(low - high.shift(1)) # |Today's Low – Yesterday’s High|# Computes the True Range (TR) as the maximum of:# - High - Low# - Absolute difference between High and Previous Close# - Absolute difference between Low and Previous Close tr = pd.concat([ high - low,abs(high - close.shift(1)),abs(low - close.shift(1)) ], axis=1).max(axis=1)# Applies a rolling window to compute the n-period sum of VM+ and VM− values# and the corresponding True Range values. sum_vm_plus = vm_plus.rolling(window=n).sum() sum_vm_minus = vm_minus.rolling(window=n).sum() sum_tr = tr.rolling(window=n).sum()# Calculates the Vortex Indicator components:# VI+ = sum of VM+ over n periods divided by sum of TR over n periods# VI− = sum of VM− over n periods divided by sum of TR over n periods vi_plus = sum_vm_plus / sum_tr vi_minus = sum_vm_minus / sum_tr# Returns the VI+ and VI− series as output.return vi_plus, vi_minus
Code
# Calculates the Vortex Indicator values for TSLA and stores the results as new columns in the DataFrame.tsla['VI+'], tsla['VI-'] = calculate_vortex(tsla, 'TSLA')# Calculates the Vortex Indicator values for XLY and stores the results as new columns in the DataFrame.xly['VI+'], xly['VI-'] = calculate_vortex(xly, 'XLY')# Calculates the Vortex Indicator values for SPY and stores the results as new columns in the DataFrame.spy['VI+'], spy['VI-'] = calculate_vortex(spy, 'SPY')
Code
# Displays the first 20 rows of the TSLA DataFrame to provide an initial overview of its structure and content with the new function applied.tsla.head(20)
Close_TSLA
High_TSLA
Low_TSLA
Open_TSLA
Volume_TSLA
VI+
VI-
Date
2019-01-02
20.674667
21.008667
19.920000
20.406668
174879000
NaN
NaN
2019-01-03
20.024000
20.626667
19.825333
20.466667
104478000
NaN
NaN
2019-01-04
21.179333
21.200001
20.181999
20.400000
110911500
NaN
NaN
2019-01-07
22.330667
22.449333
21.183332
21.448000
113268000
NaN
NaN
2019-01-08
22.356667
22.934000
21.801332
22.797333
105127500
NaN
NaN
2019-01-09
22.568666
22.900000
22.098000
22.366667
81493500
NaN
NaN
2019-01-10
22.997999
23.025999
22.119333
22.293333
90846000
NaN
NaN
2019-01-11
23.150667
23.227333
22.584667
22.806000
75586500
NaN
NaN
2019-01-14
22.293333
22.833332
22.266666
22.825333
78709500
NaN
NaN
2019-01-15
22.962000
23.253332
22.299999
22.333332
90849000
NaN
NaN
2019-01-16
23.070000
23.466667
22.900000
22.985332
70375500
NaN
NaN
2019-01-17
23.153999
23.433332
22.943333
23.080667
55150500
NaN
NaN
2019-01-18
20.150667
21.808666
19.982000
21.533333
362262000
NaN
NaN
2019-01-22
19.927999
20.533333
19.700001
20.321333
181000500
NaN
NaN
2019-01-23
19.172667
19.633333
18.779333
19.500000
187950000
0.938520
0.946160
2019-01-24
19.434000
19.578667
18.618668
18.868668
120183000
0.937771
0.927867
2019-01-25
19.802668
19.901333
19.303333
19.625999
108744000
0.969095
0.953411
2019-01-28
19.758667
19.830667
19.183332
19.527332
96349500
0.886399
1.047633
2019-01-29
19.830667
19.903999
19.453333
19.684668
69325500
0.853825
1.081611
2019-01-30
20.584667
20.600000
19.899332
20.030001
168754500
0.859650
1.020518
Calculate Volume-Weighted Sentiment
Code
def json_reader(ticker):withopen(f'{ticker}_sentiment_raw.json', "r") as f: sentiment_json_ticker = json.load(f) sentiment_feed = sentiment_json_ticker.get("feed", []) sentiment_data = []# Iterate through each item in the sentiment feed to extract relevant fieldsfor item in sentiment_feed:try: sentiment_data.append({# Convert the timestamp to pandas datetime for proper indexing"time_published": pd.to_datetime(item["time_published"]),# Convert the sentiment score string to float"sentiment_score": float(item["overall_sentiment_score"]),# Store the sentiment label (e.g., Positive, Neutral, Negative)"sentiment_label": item["overall_sentiment_label"], })except (KeyError, ValueError, TypeError):# Skip malformed or incomplete entries that raise an errorcontinue# Convert the structured list of dictionaries into a pandas DataFrame sentiment_df = pd.DataFrame(sentiment_data)# Set the 'time_published' column as the DataFrame index to enable time-series operations# sentiment_df.set_index("time_published", inplace=True) sentiment_df['time_published']= pd.to_datetime(sentiment_df['time_published'].dt.date)return sentiment_df# globals()[f"{ticker.lower()}_sentiment_data"] = sentiment_data
tsla_merged_data = pd.merge( tsla['Volume_TSLA'].reset_index().rename(columns={'Volume_TSLA': 'Volume'}), tsla_sentiment_scores_filtered, left_on='Date', right_on='time_published', how='inner')# Compute the weighted sentiment by multiplying raw sentiment by trading volumetsla_merged_data['Weighted_Sentiment'] = tsla_merged_data['Volume'] * tsla_merged_data['sentiment_score']# Calculate a 5-day rolling average of the weighted sentiment to smooth short-term noisetsla_merged_data['5_day_avg_sentiment'] = tsla_merged_data['Weighted_Sentiment'].rolling(window=5).mean()# Define a binary condition for when the average sentiment is positivetsla_merged_data['Buy_Condition'] = tsla_merged_data['5_day_avg_sentiment'] >0# Normalize the rolling sentiment score by average volume to allow comparability across scalestsla_merged_data['5_day_avg_sentiment_norm'] = ( tsla_merged_data['5_day_avg_sentiment'] / tsla_merged_data['Volume'].mean())
# Create a line chart to visualize the ATR% (Average True Range as a percentage of price) over timefig_atr_tsla = px.line(tsla, x=tsla.index, y="atr_pct", title="ATR% Over Time")# Add a horizontal reference line at 3% to represent the low-volatility cutoff thresholdfig_atr_tsla.add_hline( y=0.03, line_dash="dot", line_color="green", annotation_text="Low Volatility Cutoff")# Display the chartfig_atr_tsla.show()# Display in Streamlit# st.subheader("ATR% Over Time for TSLA")# st.plotly_chart(fig_atr_tsla, use_container_width=True)
The chart illustrates the historical volatility of TSLA, measured by the Average True Range (ATR) as a percentage of the closing price. Periods where the ATR% falls below the dotted green line at 3% indicate low volatility, which is typically associated with more stable market conditions. In contrast, noticeable spikes—such as those seen in 2020 and 2021—reflect periods of heightened volatility. More recently, ATR% values appear to remain closer to or slightly above the low-volatility threshold, suggesting relatively calmer market behavior compared to earlier years.
Code
# Filter the TSLA DataFrame to include only records from the year 2025tsla_2025 = tsla[tsla.index.year ==2025]# Create a line chart to visualize ATR% for TSLA during 2025fig = px.line( tsla_2025, x=tsla_2025.index, y="atr_pct", title="TSLA ATR% Over Time (2025 Only)")# Add a horizontal line at the 3% threshold to denote the low-volatility cutofffig.add_hline( y=0.03, line_dash="dot", line_color="green", annotation_text="Low Volatility Cutoff")# Display the chartfig.show()
The chart displays ATR% for TSLA during 2025, reflecting how the stock’s volatility has evolved since the start of the year. While ATR% began above the 7% mark in early January, it gradually declined and remained mostly between 4% and 6% throughout February. Although volatility did not breach the low-volatility threshold of 3%, the dip toward that level suggests a period of relative calm. Toward early March, ATR% showed a clear upward trend, indicating a potential resurgence in market volatility.
Code
def signal_generation(df, ticker): df['atr_pct'] = df['ATR_10'] / df['Close_'+ ticker]# Create Buy Signal (assuming VI_Cross_Up is defined elsewhere) df['Buy_Signal'] = df['VI+'] > df['VI-'] # Vortex crossover# + add any other buy conditions here...# Create Sell Signal (basic) df['Sell_Signal'] = df['VI-'] > df['VI+']# Initialize position state df['Position'] =0 peak_price =0for i inrange(1, len(df)):if df['Buy_Signal'].iloc[i]: df.at[df.index[i], 'Position'] =1 peak_price = df['Close_'+ ticker].iloc[i]elif df['Position'].iloc[i -1] ==1: current_price = df['Close_'+ ticker].iloc[i] peak_price =max(peak_price, current_price) drawdown = (peak_price - current_price) / peak_priceif drawdown >=0.03: df.at[df.index[i], 'Sell_Signal'] =True# trailing stop df.at[df.index[i], 'Position'] =0else: df.at[df.index[i], 'Position'] =1return df
Code
tsla = signal_generation(tsla, 'TSLA')# Display the total number of buy and sell signals generated across the datasetprint("Buy signals:", tsla['Buy_Signal'].sum())print("Sell signals:", tsla['Sell_Signal'].sum())
Buy signals: 857
Sell signals: 680
Code
# Create an empty figure objectfig = go.Figure()# Plot the TSLA closing price as a continuous linefig.add_trace(go.Scatter( x=tsla.index, y=tsla['Close_TSLA'], mode='lines', name='TSLA Price'))# Add markers to indicate Buy Signals using upward-pointing green trianglesfig.add_trace(go.Scatter( x=tsla[tsla['Buy_Signal']].index, y=tsla[tsla['Buy_Signal']]['Close_TSLA'], mode='markers', marker=dict(symbol='triangle-up', size=10, color='green'), name='Buy Signal'))# Add markers to indicate Sell Signals using downward-pointing red trianglesfig.add_trace(go.Scatter( x=tsla[tsla['Sell_Signal']].index, y=tsla[tsla['Sell_Signal']]['Close_TSLA'], mode='markers', marker=dict(symbol='triangle-down', size=10, color='red'), name='Sell Signal'))# Update layout settings including title and visual stylefig.update_layout( title='TSLA Buy & Sell Signals', template='plotly_white')# Render the interactive plotfig.show()
The chart illustrates the closing price of Tesla stock over time, with overlaid trading signals generated by the strategy. Green upward triangles represent buy signals, while red downward triangles mark sell signals. These signals are distributed throughout periods of both rising and falling prices, reflecting how the algorithm dynamically enters and exits positions based on market conditions. Clusters of signals during high-volatility periods—such as 2020, 2021, and early 2025—indicate frequent entries and exits, whereas more stable phases show fewer trades.
Code
# Calculate ATR as a percentage of the closing price to normalize volatilitytsla['atr_pct'] = tsla['ATR_10'] / tsla['Close_TSLA']# Define Vortex Indicator crossover signals:# - VI_Cross_Up: Identifies when VI+ crosses above VI− (potential bullish signal)# - VI_Cross_Down: Identifies when VI− crosses above VI+ (potential bearish signal)tsla['VI_Cross_Up'] = (tsla['VI+'] > tsla['VI-']) & (tsla['VI+'].shift(1) <= tsla['VI-'].shift(1))tsla['VI_Cross_Down'] = (tsla['VI-'] > tsla['VI+']) & (tsla['VI-'].shift(1) <= tsla['VI+'].shift(1))# Initialize signal and state columnstsla['Buy_Signal'] =False# Flag for buy signaltsla['Sell_Signal'] =False# Flag for sell signaltsla['Position'] =0# Position state: 1 = in position, 0 = no positiontsla['Entry_Type'] =None# Strategy classification: 'aggressive' or 'conservative'# Initialize control variables for trailing stop and price trackingin_position =False# Boolean flag for current position statepeak_price =0# Highest price observed during an open position# Iterate through the DataFrame to simulate trading logic based on Vortex signals and volatilityfor i inrange(1, len(tsla)): row = tsla.iloc[i] idx = tsla.index[i]# Buy condition: Enter a new position if VI_Cross_Up occurs and no current position is heldifnot in_position and row['VI_Cross_Up']: tsla.at[idx, 'Buy_Signal'] =True tsla.at[idx, 'Position'] =1 in_position =True peak_price = row['Close_TSLA']# Classify entry type based on volatility thresholdif row['atr_pct'] <0.03: tsla.at[idx, 'Entry_Type'] ='aggressive'else: tsla.at[idx, 'Entry_Type'] ='conservative'# While in position, evaluate for trailing stop or VI_Cross_Down exit conditionelif in_position: current_price = row['Close_TSLA'] peak_price =max(peak_price, current_price) drawdown = (peak_price - current_price) / peak_price# Sell condition: Exit if drawdown exceeds 3% or VI_Cross_Down occursif drawdown >=0.03or row['VI_Cross_Down']: tsla.at[idx, 'Sell_Signal'] =True tsla.at[idx, 'Position'] =0 in_position =Falseelse: tsla.at[idx, 'Position'] =1# Maintain position# Output the total count of each type of signal and entry classificationprint("Buy signals:", tsla['Buy_Signal'].sum())print("Sell signals:", tsla['Sell_Signal'].sum())print("Aggressive entries:", (tsla['Entry_Type'] =='aggressive').sum())print("Conservative entries:", (tsla['Entry_Type'] =='conservative').sum())
# Create an empty figure to hold all plot layersfig = go.Figure()# Plot the tsla closing price as a continuous blue linefig.add_trace(go.Scatter( x=tsla.index, y=tsla['Close_TSLA'], mode='lines', name='TSLA Price', line=dict(color='blue')))# Add markers for aggressive buy signals (Entry_Type = 'aggressive')fig.add_trace(go.Scatter( x=tsla[(tsla['Buy_Signal']) & (tsla['Entry_Type'] =='aggressive')].index, y=tsla[(tsla['Buy_Signal']) & (tsla['Entry_Type'] =='aggressive')]['Close_TSLA'], mode='markers', name='Buy (Aggressive)', marker=dict(symbol='triangle-up', color='limegreen', size=10)))# Add markers for conservative buy signals (Entry_Type = 'conservative')fig.add_trace(go.Scatter( x=tsla[(tsla['Buy_Signal']) & (tsla['Entry_Type'] =='conservative')].index, y=tsla[(tsla['Buy_Signal']) & (tsla['Entry_Type'] =='conservative')]['Close_TSLA'], mode='markers', name='Buy (Conservative)', marker=dict(symbol='triangle-up', color='green', size=10)))# Add markers for sell signals using red downward-pointing trianglesfig.add_trace(go.Scatter( x=tsla[tsla['Sell_Signal']].index, y=tsla[tsla['Sell_Signal']]['Close_TSLA'], mode='markers', name='Sell Signal', marker=dict(symbol='triangle-down', color='red', size=10)))# Configure chart layout with appropriate title, axis labels, and stylefig.update_layout( title='TSLA Buy/Sell Signals Over Time', xaxis_title='Date', yaxis_title='Price (USD)', template='plotly_white', height=600)# Render the figurefig.show()
The chart displays the historical closing price of Tesla (TSLA) stock alongside algorithmically generated buy and sell signals. The blue line represents TSLA’s closing price, while the green upward-pointing triangles indicate buy entries—distinguished by lime green for aggressive entries (lower volatility) and dark green for conservative entries (higher volatility). Red downward-pointing triangles represent sell signals.
The buy signals are generally aligned with upward momentum, and sell signals frequently follow periods of short-term retracement or heightened volatility. The system shows particularly dense activity around highly volatile phases, such as mid-2020 to early 2022, capturing many entries and exits. In contrast, during more stable periods, the signals are more spaced out. Overall, the plot provides a clear visual assessment of how the strategy adapts dynamically to changing market conditions by modulating its entries based on volatility and exiting with protective trailing logic.
# without centiment datatsla_portfolio = f_portfolio(tsla, 'TSLA')print(tsla_portfolio.stats())tsla_portfolio.plot().show()
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
Start 2019-01-02 00:00:00
End 2025-03-04 00:00:00
Period 1551
Start Value 100000.0
End Value 162759.235978
Total Return [%] 62.759236
Benchmark Return [%] 1215.813231
Max Gross Exposure [%] 100.0
Total Fees Paid 24054.581607
Max Drawdown [%] 55.348959
Max Drawdown Duration 730.0
Total Trades 80
Total Closed Trades 80
Total Open Trades 0
Open Trade PnL 0.0
Win Rate [%] 32.5
Best Trade [%] 46.283397
Worst Trade [%] -9.410141
Avg Winning Trade [%] 11.344578
Avg Losing Trade [%] -3.847352
Avg Winning Trade Duration 7.076923
Avg Losing Trade Duration 2.537037
Profit Factor 1.194803
Expectancy 784.49045
dtype: object
The backtest results show that while the strategy achieved a total return of approximately 62.76%, it significantly underperformed compared to a simple buy-and-hold strategy on TSLA, which yielded a 1215.81% return. The strategy executed 80 trades with a low win rate of 32.5%, indicating that most trades were unprofitable. Although it had a few strong winners, the average profit per trade was marginal, with a profit factor of 1.19. Additionally, the portfolio experienced a substantial maximum drawdown of 55.35% and a prolonged recovery period lasting two years, signaling high risk. Visuals further confirm that many trades resulted in small losses or gains, with only a few notable profitable exits. Overall, while the strategy demonstrates some profitability, its risk-return profile is weak and may require optimization in entry/exit logic, volatility filtering, or sentiment integration to compete with the benchmark performance.
Start 2019-01-02 00:00:00
End 2025-03-04 00:00:00
Period 1551
Start Value 100000.0
End Value 170848.194798
Total Return [%] 70.848195
Benchmark Return [%] 120.815504
Max Gross Exposure [%] 100.0
Total Fees Paid 21558.870642
Max Drawdown [%] 33.668417
Max Drawdown Duration 793.0
Total Trades 76
Total Closed Trades 76
Total Open Trades 0
Open Trade PnL 0.0
Win Rate [%] 44.736842
Best Trade [%] 37.025745
Worst Trade [%] -13.070482
Avg Winning Trade [%] 4.635492
Avg Losing Trade [%] -2.172936
Avg Winning Trade Duration 22.558824
Avg Losing Trade Duration 4.690476
Profit Factor 1.512842
Expectancy 932.213089
dtype: object
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
Start 2019-01-02 00:00:00
End 2025-03-04 00:00:00
Period 1551
Start Value 100000.0
End Value 149876.046124
Total Return [%] 49.876046
Benchmark Return [%] 153.411688
Max Gross Exposure [%] 100.0
Total Fees Paid 14500.381668
Max Drawdown [%] 19.809446
Max Drawdown Duration 584.0
Total Trades 56
Total Closed Trades 56
Total Open Trades 0
Open Trade PnL 0.0
Win Rate [%] 55.357143
Best Trade [%] 7.385099
Worst Trade [%] -9.885438
Avg Winning Trade [%] 3.135409
Avg Losing Trade [%] -2.130089
Avg Winning Trade Duration 28.258065
Avg Losing Trade Duration 7.56
Profit Factor 1.712196
Expectancy 890.643681
dtype: object
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
Optimization (TSLA)
Code
# Define a list of different smoothing periods to test for the Vortex Indicatorperiods = [7, 14, 21, 30]results = {} # Dictionary to store performance metrics for each period# Loop through each smoothing periodfor n in periods:# === Compute Vortex Indicator for the given period === tsla[f'VI+{n}'], tsla[f'VI-{n}'] = calculate_vortex(tsla, 'TSLA', n)# === Generate Buy/Sell signals based on crossover logic ===# Buy when VI+ crosses above VI- tsla[f'Buy_{n}'] = tsla[f'VI+{n}'] > tsla[f'VI-{n}']# Sell when VI- crosses above VI+ tsla[f'Sell_{n}'] = tsla[f'VI-{n}'] > tsla[f'VI+{n}']# === Convert boolean signals to actual entry/exit Series === entries = tsla[f'Buy_{n}'] exits = tsla[f'Sell_{n}']# === Run a backtest using vectorbt Portfolio object === portfolio = vbt.Portfolio.from_signals( close=tsla['Close_TSLA'], # TSLA closing prices entries=entries, exits=exits, size=1, # Assume buying 1 share per trade init_cash=10_000# Initial capital for backtest )# === Store backtest performance metrics in results dict === stats = portfolio.stats() results[n] = stats# Identify the period with the highest total returnbest_period =max(results, key=lambda x: results[x]['Total Return [%]'])print(f"✅ Best Performing Period: {best_period} days")# Rebuild portfolio using the best period to visualize itportfolio = vbt.Portfolio.from_signals( close=tsla['Close_TSLA'], entries=tsla[f'VI+{best_period}'] > tsla[f'VI-{best_period}'], exits=tsla[f'VI-{best_period}'] > tsla[f'VI+{best_period}'], size=1, init_cash=10_000)# Plot the results of the best strategyportfolio.plot().show()print(portfolio.stats())
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
✅ Best Performing Period: 7 days
Start 2019-01-02 00:00:00
End 2025-03-04 00:00:00
Period 1551
Start Value 10000.0
End Value 10480.194603
Total Return [%] 4.801946
Benchmark Return [%] 1215.813231
Max Gross Exposure [%] 4.554966
Total Fees Paid 0.0
Max Drawdown [%] 0.793073
Max Drawdown Duration 351.0
Total Trades 113
Total Closed Trades 113
Total Open Trades 0
Open Trade PnL 0.0
Win Rate [%] 44.247788
Best Trade [%] 128.434899
Worst Trade [%] -15.721837
Avg Winning Trade [%] 14.052436
Avg Losing Trade [%] -4.125181
Avg Winning Trade Duration 11.44
Avg Losing Trade Duration 4.206349
Profit Factor 2.096188
Expectancy 4.24951
dtype: object
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
aapl = signal_generation(aapl, 'AAPL')# Display the total number of buy and sell signals generated across the datasetprint("Buy signals:", aapl['Buy_Signal'].sum())print("Sell signals:", aapl['Sell_Signal'].sum())
Buy signals: 985
Sell signals: 552
Code
# Calculate ATR as a percentage of the closing price to normalize volatilityaapl['atr_pct'] = aapl['ATR_10'] / aapl['Close_AAPL']# Define Vortex Indicator crossover signals:# - VI_Cross_Up: Identifies when VI+ crosses above VI− (potential bullish signal)# - VI_Cross_Down: Identifies when VI− crosses above VI+ (potential bearish signal)aapl['VI_Cross_Up'] = (aapl['VI+'] > aapl['VI-']) & (aapl['VI+'].shift(1) <= aapl['VI-'].shift(1))aapl['VI_Cross_Down'] = (aapl['VI-'] > aapl['VI+']) & (aapl['VI-'].shift(1) <= aapl['VI+'].shift(1))# Initialize signal and state columnsaapl['Buy_Signal'] =False# Flag for buy signalaapl['Sell_Signal'] =False# Flag for sell signalaapl['Position'] =0# Position state: 1 = in position, 0 = no positionaapl['Entry_Type'] =None# Strategy classification: 'aggressive' or 'conservative'# Initialize control variables for trailing stop and price trackingin_position =False# Boolean flag for current position statepeak_price =0# Highest price observed during an open position# Iterate through the DataFrame to simulate trading logic based on Vortex signals and volatilityfor i inrange(1, len(aapl)): row = aapl.iloc[i] idx = aapl.index[i]# Buy condition: Enter a new position if VI_Cross_Up occurs and no current position is heldifnot in_position and row['VI_Cross_Up']: aapl.at[idx, 'Buy_Signal'] =True aapl.at[idx, 'Position'] =1 in_position =True peak_price = row['Close_AAPL']# Classify entry type based on volatility thresholdif row['atr_pct'] <0.03: aapl.at[idx, 'Entry_Type'] ='aggressive'else: aapl.at[idx, 'Entry_Type'] ='conservative'# While in position, evaluate for trailing stop or VI_Cross_Down exit conditionelif in_position: current_price = row['Close_AAPL'] peak_price =max(peak_price, current_price) drawdown = (peak_price - current_price) / peak_price# Sell condition: Exit if drawdown exceeds 3% or VI_Cross_Down occursif drawdown >=0.03or row['VI_Cross_Down']: aapl.at[idx, 'Sell_Signal'] =True aapl.at[idx, 'Position'] =0 in_position =Falseelse: aapl.at[idx, 'Position'] =1# Maintain position# Output the total count of each type of signal and entry classificationprint("Buy signals:", aapl['Buy_Signal'].sum())print("Sell signals:", aapl['Sell_Signal'].sum())print("Aggressive entries:", (aapl['Entry_Type'] =='aggressive').sum())print("Conservative entries:", (aapl['Entry_Type'] =='conservative').sum())
aapl_merged_data = pd.merge( aapl['Volume_AAPL'].reset_index().rename(columns={'Volume_AAPL': 'Volume'}), aapl_sentiment_scores_filtered, left_on='Date', right_on='time_published', how='inner')# Compute the weighted sentiment by multiplying raw sentiment by trading volumeaapl_merged_data['Weighted_Sentiment'] = aapl_merged_data['Volume'] * aapl_merged_data['sentiment_score']# Calculate a 5-day rolling average of the weighted sentiment to smooth short-term noiseaapl_merged_data['5_day_avg_sentiment'] = aapl_merged_data['Weighted_Sentiment'].rolling(window=5).mean()# Define a binary condition for when the average sentiment is positiveaapl_merged_data['Buy_Condition'] = aapl_merged_data['5_day_avg_sentiment'] >0# Normalize the rolling sentiment score by average volume to allow comparability across scalesaapl_merged_data['5_day_avg_sentiment_norm'] = ( aapl_merged_data['5_day_avg_sentiment'] / aapl_merged_data['Volume'].mean())
# Ensure 'Date' is datetime and set as index if neededaapl_merged_data['Date'] = pd.to_datetime(aapl_merged_data['Date'])fig = go.Figure()# Plot 5-day Avg Sentimentfig.add_trace(go.Scatter( x=aapl_merged_data['Date'], y=aapl_merged_data['5_day_avg_sentiment_norm'], mode='lines+markers', name='5-Day Avg Sentiment', line=dict(color='blue')))# Plot ATR %fig.add_trace(go.Scatter( x=aapl_merged_data['Date'], y=aapl_merged_data['atr_pct'], mode='lines+markers', name='ATR %', yaxis='y2', line=dict(color='orange')))# Optional: Highlight Buy Signal Dates (even though there are none now)fig.add_trace(go.Scatter( x=aapl_merged_data.loc[aapl_merged_data['Buy_Signal'], 'Date'], y=aapl_merged_data.loc[aapl_merged_data['Buy_Signal'], '5_day_avg_sentiment_norm'], mode='markers', marker=dict(color='green', size=10, symbol='star'), name='Buy Signal'))# Add dual axis layoutfig.update_layout( title="5-Day Sentiment vs ATR % (with Buy Signals)", xaxis_title='Date', yaxis=dict(title='5-Day Avg Sentiment'), yaxis2=dict(title='ATR %', overlaying='y', side='right'), legend=dict(x=0.01, y=0.99), height=500)fig.show()
Code
print(backtest(aapl_merged_data, 'AAPL')) #w/ sentiment data
Final Capital: $100057.01
Total Return: $57.01
Total Trades: 1
Average Profit per Trade: $-24.62
Code
print(backtest(aapl, 'AAPL')) #w/o sentiment data
Final Capital: $101198.29
Total Return: $1198.29
Total Trades: 66
Average Profit per Trade: $18.16
Code
# without centiment dataaapl_portfolio = f_portfolio(aapl, 'AAPL')print(aapl_portfolio.stats())aapl_portfolio.plot().show()
Start 2019-01-02 00:00:00
End 2025-03-04 00:00:00
Period 1551
Start Value 100000.0
End Value 255472.954051
Total Return [%] 155.472954
Benchmark Return [%] 526.354355
Max Gross Exposure [%] 100.0
Total Fees Paid 23330.2112
Max Drawdown [%] 14.949616
Max Drawdown Duration 238.0
Total Trades 66
Total Closed Trades 66
Total Open Trades 0
Open Trade PnL 0.0
Win Rate [%] 48.484848
Best Trade [%] 18.319731
Worst Trade [%] -5.776766
Avg Winning Trade [%] 5.44886
Avg Losing Trade [%] -2.071776
Avg Winning Trade Duration 16.5625
Avg Losing Trade Duration 4.176471
Profit Factor 2.213935
Expectancy 2355.650819
dtype: object
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sharpe_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'calmar_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'omega_ratio' requires frequency to be set
/Users/dinarazhorabek/Library/Python/3.9/lib/python/site-packages/vectorbt/generic/stats_builder.py:396: UserWarning:
Metric 'sortino_ratio' requires frequency to be set
Based on the results from applying the trading strategy to the Apple (AAPL) ticker, we can reasonably conclude that the strategy does work on peers like AAPL. The strategy delivered a total return of approximately 282% over the backtest period (2019–2025), compared to a benchmark return of about 526%, which indicates it captured a significant portion of the upward trend while actively managing trades. Although it underperformed the benchmark in absolute terms, this is typical of signal-driven strategies that trade in and out of the market. The profit factor of 2.11, expectancy of 4204, and a win rate of 45.5% suggest the strategy was profitable overall. Additionally, the drawdown was moderate (20.87%), reflecting a reasonable risk exposure relative to the potential reward.
The cumulative returns graph further supports this interpretation. The strategy closely follows the broader market trend, generating consistent gains and outperforming during certain periods. The trade PnL distribution shows a good number of winning trades with healthy profitability, and although there were losses, the downside was generally contained. Therefore, this peer comparison confirms that the strategy generalizes reasonably well beyond TSLA, making it a potentially viable approach for other high-liquidity technology stocks like AAPL.