True Strength Index
The True Strength Index (TSI) is a momentum oscillator that measures the strength and direction of price trends by applying double smoothing to price momentum. Developed by William Blau, it helps traders identify overbought and oversold conditions while filtering out short-term price noise through its unique double exponential moving average calculation.
What It Measures
The TSI measures the momentum of price changes by applying two exponential moving averages to the difference between price changes, creating a smoothed oscillator that ranges typically between -100 and +100. This double smoothing technique reduces market noise and provides clearer signals about the underlying trend strength and potential reversals.
When to Use
Use the TSI when you want to identify trend direction and strength while filtering out false signals that often occur with single-smoothed indicators. It's particularly effective in trending markets where you need to distinguish between meaningful momentum shifts and temporary price fluctuations, making it valuable for both trend-following and reversal trading strategies.
Interpretation
The TSI oscillates around a zero centerline, with positive values indicating bullish momentum and negative values indicating bearish momentum. Values crossing above the overbought threshold (typically +25) suggest potential selling opportunities when momentum is extended, while values crossing below the oversold threshold (typically -25) suggest potential buying opportunities when downward momentum may be exhausted. Divergences between price and TSI can also signal potential trend reversals.
Default Usage
use rust_ti::trend_indicators::bulk::true_strength_index;
use rust_ti::ConstantModelType;
pub fn main() {
// fetch the data in your preferred way
// let close = vec![...]; // closing prices
let tsi = true_strength_index(
&close,
ConstantModelType::ExponentialMovingAverage,
25,
ConstantModelType::ExponentialMovingAverage,
13,
);
println!("{:?}", tsi);
}
import pytechnicalindicators as pti
# fetch the data in your preferred way
# close = [...] # closing prices
tsi = pti.trend_indicators.bulk.true_strength_index(close, first_period=25, second_period=13, first_model='ExponentialMovingAverage', second_model='ExponentialMovingAverage')
print(tsi)
// WASM import
import init, { trend_bulk_trueStrengthIndex, ConstantModelType } from 'https://cdn.jsdelivr.net/npm/ti-engine@latest/dist/web/ti_engine.js';
await init();
// fetch the data in your preferred way
// const close = [...]; // closing prices
const tsi = trend_bulk_trueStrengthIndex(close, ConstantModelType["ExponentialMovingAverage"], 25, ConstantModelType["ExponentialMovingAverage"], 13);
console.log(tsi);
Optimization
The best way to determine what the best parameters for your indicator are is to build a simple optimization loop that tests all possible parameter combinations between a defined min and max value, and rate the output.
Below is an example of how to do this in Rust.
use rust_ti::chart_trends::{peaks, valleys};
fn proximity_rating(fuzzed_location: &usize, price_location: &usize) -> f64 {
1.0 / (*fuzzed_location as f64 - *price_location as f64).abs()
}
pub fn main() {
// fetch the data in your preferred way
let optimization_start = Instant::now();
// Get buy and sell points
let sell_points = peaks(&close, 20, 5)
.into_iter()
.map(|(_, i)| i)
.collect::<Vec<usize>>();
let buy_points = valleys(&close, 20, 5)
.into_iter()
.map(|(_, i)| i)
.collect::<Vec<usize>>();
// Define the ranges for optimization
let max_first_period = 30;
let min_first_period = 10;
let max_second_period = 20;
let min_second_period = 5;
// TSI bounds typically range from -100 to +100, but we'll use -50 to +50 for optimization
let oversold_values: Vec<i32> = (0..=50).step_by(5).collect(); // Will be negated
let overbought_values: Vec<i32> = (0..=50).step_by(5).collect();
let fuzz_parameter = 5;
// Store the best parameters found
let mut best_rating = 0.0;
let mut best_first_period = 0;
let mut best_second_period = 0;
let mut best_first_model = ConstantModelType::SimpleMovingAverage;
let mut best_second_model = ConstantModelType::SimpleMovingAverage;
let mut best_oversold = 0.0;
let mut best_overbought = 0.0;
let mut best_indicators = vec![];
let ma_types = vec![
ConstantModelType::SimpleMovingAverage,
ConstantModelType::ExponentialMovingAverage,
ConstantModelType::SmoothedMovingAverage,
];
let total_iterations = (max_first_period - min_first_period + 1)
* (max_second_period - min_second_period + 1)
* ma_types.len()
* ma_types.len()
* oversold_values.len()
* overbought_values.len();
let mut iteration_count = 0;
let mut last_logged_percent = 0;
println!("
Running optimization loop with {} total iterations...", total_iterations);
for first_ma_type in &ma_types {
for second_ma_type in &ma_types {
for first_period in min_first_period..=max_first_period {
for second_period in min_second_period..=max_second_period {
let min_length = first_period + second_period;
if close.len() < min_length {
continue;
}
let indicators = true_strength_index(
&close,
*first_ma_type,
first_period,
*second_ma_type,
second_period,
);
for &oversold in &oversold_values {
for &overbought in &overbought_values {
iteration_count += 1;
let current_percent = (iteration_count * 100) / total_iterations;
if current_percent > last_logged_percent && current_percent % 5 == 0 {
let elapsed = optimization_start.elapsed().as_secs();
let estimated_total = if iteration_count > 0 {
(elapsed * total_iterations as u64) / iteration_count as u64
} else {
0
};
let estimated_remaining = estimated_total.saturating_sub(elapsed);
println!(
"Optimization is {}% complete... (Elapsed: {}, Est. remaining: {})",
current_percent,
format_duration(elapsed),
format_duration(estimated_remaining)
);
last_logged_percent = current_percent;
}
// Convert to negative for oversold, keep positive for overbought
let oversold_threshold = -(oversold as f64);
let overbought_threshold = overbought as f64;
// Bounds must be different and oversold < overbought
if oversold_threshold >= overbought_threshold {
continue;
}
let mut rating = vec![];
let mut matched_sell = vec![];
let mut matched_buy = vec![];
// TSI signal logic:
// - TSI > overbought threshold = overbought (potential sell)
// - TSI < oversold threshold = oversold (potential buy)
for i in 0..indicators.len() {
let price_location = i + min_length;
if price_location >= close.len() {
break;
}
let tsi = indicators[i];
// Overbought signal (should match sell points)
if tsi > overbought_threshold {
if sell_points.contains(&price_location) {
rating.push(1.0);
matched_sell.push(price_location);
} else if buy_points.contains(&price_location) {
rating.push(-1.0);
} else {
let mut found_sell = false;
for fuzzed in price_location.saturating_sub(fuzz_parameter)
..=price_location + fuzz_parameter
{
if sell_points.contains(&fuzzed) {
rating.push(proximity_rating(&fuzzed, &price_location));
matched_sell.push(fuzzed);
found_sell = true;
break;
}
}
if !found_sell {
for fuzzed in price_location.saturating_sub(fuzz_parameter)
..=price_location + fuzz_parameter
{
if buy_points.contains(&fuzzed) {
rating.push(-proximity_rating(&fuzzed, &price_location));
break;
}
}
}
}
}
// Oversold signal (should match buy points)
else if tsi < oversold_threshold {
if buy_points.contains(&price_location) {
rating.push(1.0);
matched_buy.push(price_location);
} else if sell_points.contains(&price_location) {
rating.push(-1.0);
} else {
let mut found_buy = false;
for fuzzed in price_location.saturating_sub(fuzz_parameter)
..=price_location + fuzz_parameter
{
if buy_points.contains(&fuzzed) {
rating.push(proximity_rating(&fuzzed, &price_location));
matched_buy.push(fuzzed);
found_buy = true;
break;
}
}
if !found_buy {
for fuzzed in price_location.saturating_sub(fuzz_parameter)
..=price_location + fuzz_parameter
{
if sell_points.contains(&fuzzed) {
rating.push(-proximity_rating(&fuzzed, &price_location));
break;
}
}
}
}
}
}
// Penalize missed points
for &missed_sell in &sell_points {
if !matched_sell.contains(&missed_sell) {
rating.push(-1.0);
}
}
for &missed_buy in &buy_points {
if !matched_buy.contains(&missed_buy) {
rating.push(-1.0);
}
}
if !rating.is_empty() {
let total_rating: f64 = rating.iter().sum::<f64>() / (rating.len() as f64);
if total_rating > best_rating {
best_rating = total_rating;
best_first_period = first_period;
best_second_period = second_period;
best_first_model = *first_ma_type;
best_second_model = *second_ma_type;
best_oversold = oversold_threshold;
best_overbought = overbought_threshold;
best_indicators = indicators.clone();
}
}
}
}
}
}
}
}
println!(
"
Indicators optimization loop took {} to run",
format_duration(optimization_start.elapsed().as_secs())
);
println!("
Best TSI parameters found:");
println!("First period: {}", best_first_period);
println!("Second period: {}", best_second_period);
println!("First model: {:?}", best_first_model);
println!("Second model: {:?}", best_second_model);
println!("Oversold threshold: {:.2}", best_oversold);
println!("Overbought threshold: {:.2}", best_overbought);
println!("Rating: {:.4}", best_rating);
}
Optimization Output
Below is an example output from the optimization code above run on a year of S&P data.
Best TSI parameters found:
First period: 12
Second period: 5
First model: ExponentialMovingAverage
Second model: ExponentialMovingAverage
Oversold threshold: -0.00
Overbought threshold: 28.00
Rating: 0.5402
Interactive Chart
To better illustrate how the indicator performs with different parameters, an interactive chart is provided below comparing default parameters (blue) with optimized parameters (green).
Analysis
The optimized TSI parameters generate significantly more trading signals (17 trades) compared to the default parameters (3 trades), providing more opportunities to capture short-term price movements. The optimized strategy achieved a total profit of $13.10 with a final capital of $1013.10, slightly outperforming the default strategy's profit of $11.40 and final capital of $1011.40. However, when considering the open positions at the last price, both strategies hold similar position values ($5638.94), meaning the optimized parameters' advantage comes from more frequent trading that accumulated small gains over time. The higher frequency of trades in the optimized strategy suggests it is more sensitive to momentum shifts, though this also means more exposure to transaction costs in real-world trading scenarios.
Optimized trading simulation
- SideLONG
- Shares0.0344
- Entry$5955.25
- Value$5638.94
Default trading simulation
- SideLONG
- Shares0.0352
- Entry$5770.20
- Value$5638.94
Trading simulation code
For those you want to run their own simulation to compare results
use rust_ti::trend_indicators::bulk::true_strength_index;
use rust_ti::ConstantModelType;
fn main() {
// Fetch data and perform optimization as shown in the optimization code above
// Run trading simulation with optimized parameters
simulate_trading(
&best_indicators,
best_first_period,
best_second_period,
best_oversold,
best_overbought,
&close,
);
// Compare with default parameters (25 EMA, 13 EMA, -25/+25 thresholds)
println!("
Default Indicator values for comparison:");
let default_tsi = true_strength_index(
&close,
ConstantModelType::ExponentialMovingAverage,
25,
ConstantModelType::ExponentialMovingAverage,
13,
);
simulate_trading(&default_tsi, 25, 13, -25.0, 25.0, &close);
}
fn simulate_trading(
indicators: &[f64],
first_period: usize,
second_period: usize,
oversold: f64,
overbought: f64,
close: &[f64],
) {
println!("
--- Trading Simulation ---");
println!("Using oversold: {:.2}, overbought: {:.2}", oversold, overbought);
let initial_capital = 1000.0;
let mut capital = initial_capital;
let investment_pct = 0.20;
let mut position: Option<(f64, f64)> = None; // (entry_price, shares)
let min_length = first_period + second_period;
println!(
"{:<5} | {:<12} | {:<10} | {:<10} | {:<10} | {:<12} | {:<10}",
"Day", "Event", "TSI", "Price", "Shares", "Capital", "P/L"
);
println!("{}", "-".repeat(80));
for i in 0..indicators.len() {
let price_location = i + min_length;
if price_location >= close.len() {
break;
}
let tsi = indicators[i];
let current_price = close[price_location];
// Sell signal: TSI crosses above overbought threshold
if let Some((entry_price, shares)) = position.take() {
if tsi > overbought {
let sale_value = shares * current_price;
let profit = sale_value - (shares * entry_price);
capital += sale_value;
println!(
"{:<5} | {:<12} | {:<10.4} | ${:<9.2} | {:<10.4} | ${:<11.2} | ${:<9.2}",
price_location, "Sell", tsi, current_price, shares, capital, profit
);
} else {
position = Some((entry_price, shares));
}
}
// Buy signal: TSI crosses below oversold threshold
else if tsi < oversold {
let investment = capital * investment_pct;
let shares = investment / current_price;
position = Some((current_price, shares));
capital -= investment;
println!(
"{:<5} | {:<12} | {:<10.4} | ${:<9.2} | {:<10.4} | ${:<11.2} | {}",
price_location, "Buy", tsi, current_price, shares, capital, "-"
);
}
}
println!("
--- Final Results ---");
if let Some((entry_price, shares)) = position {
let last_price = close.last().unwrap_or(&0.0);
let current_value = shares * last_price;
capital += current_value;
let unrealized_pnl = current_value - (shares * entry_price);
println!("Position still open:");
println!(" Entry: ${:.2}, Current: ${:.2}", entry_price, last_price);
println!(" Unrealized P/L: ${:.2}", unrealized_pnl);
}
let final_pnl = capital - initial_capital;
println!("
Initial Capital: ${:.2}", initial_capital);
println!("Final Capital: ${:.2}", capital);
println!("Total P/L: ${:.2} ({:.2}%)", final_pnl, (final_pnl / initial_capital) * 100.0);
}