Sentiment Pulse Indicator For ThinkOrSwim

TRADERSAM

Member
VIP
Been working on a “Sentiment Pulse Oscillator” for a little while now and just wanted to share it with everyone. This indicator combines RSI, CCI, and OBV into one normalized reading between –1 and +1. It smooths the result, shades the background into sentiment zones, and plots vertical lines labeled “BUY” or “SELL” whenever the sentiment crosses into strong bullish or bearish alignment. The idea is to simplify multiple indicators into a single clean view of momentum, trend, and volume pressure. It’s been working well in my testing, but I know it’s far from perfect, so I’d love any suggestions on how to make it more accurate, responsive, or visually clear.

KpGEnIf.png

Here is the code:
Ruby:
declare lower;

# === INPUTS ===
input rsiLength = 14;
input cciLength = 20;
input obvLength = 20;

input cciNormFactor = 200.0;  # Scale for CCI --> [-1,1]
input zScale = 3.0;  # number of stdevs to map OBV z-score

input wRSI = 1.0;
input wCCI = 1.0;
input wOBV = 1.0;

input smoothlength = 3; # Smoothing for final oscillator

input strongThreshold = 0.7;
input mildThreshold = 0.15;

input showcomponents = yes;
input showclouds = yes;

# === Raw component series ===
def rsi = RSI(rsilength);
def cci = CCI(CCIlength);
def obv = onbalancevolume();

# === RSI normalization: 0..100 -> -1..1 ===
def rsiNorm = (rsi - 50) /50;
def rsiNormClip = if rsinorm > 1 then 1 else if rsinorm < -1 then -1 else rsiNorm;

# === CCI normalization: divide by factor then clip ===
def ccinorm = cci / ccinormFactor;
def ccinormclip = if ccinorm > 1 then 1 else if ccinorm < -1 then -1 else ccinorm;

# === OBV normalization via z-score then scale & clip ===
def obvMean = Average(obv, obvlength);
def obvStDev = stdev(obv, obvlength);
def obvz = if obvstdev != 0 then (obv - obvmean) / obvstdev else 0;
def obvnorm = obvz / zscale;
def obvnormclip = if obvnorm > 1 then 1 else if obvnorm < -1 then -1 else obvnorm;

# === Combine with weights ===
def totalweight = wrsi + wcci + wobv;
def sentRaw = (wrsi * rsinormclip + wcci *ccinormclip + wobv * obvnormclip) / totalweight;

# === Smoothlength & final clip to [-1, 1] ===
def sent = expaverage(sentraw, smoothlength);
def sentfinal = if sent > 1 then 1 else if sent < -1 then -1 else sent;

# === Main plot ===
plot sentiment = sentfinal;
sentiment.setDefaultColor(color.cyan);
sentiment.setLineWeight(3);

# === Zero Line ===
plot zeroline = 0;
zeroline.setDefaultColor(color.white);
Zeroline.SetLineWeight(3);

# === Optional component plots ===
plot RSI_Line = if showComponents then rsiNormClip else Double.NaN;
RSI_Line.SetDefaultColor(Color.YELLOW);
RSI_Line.SetLineWeight(1);

plot CCI_Line = if showComponents then cciNormClip else Double.NaN;
CCI_Line.SetDefaultColor(Color.MAGENTA);
CCI_Line.SetLineWeight(1);

plot OBV_Line = if showComponents then obvNormClip else Double.NaN;
OBV_Line.SetDefaultColor(Color.RED);
OBV_Line.SetLineWeight(1);

# === Clouds (5 zones) ===
plot top1 = if showClouds then 1 else Double.NaN;
plot topStrong = if showClouds then strongThreshold else Double.NaN;
plot topMild = if showClouds then mildThreshold else Double.NaN;
plot botMild = if showClouds then -mildThreshold else Double.NaN;
plot botStrong = if showClouds then -strongThreshold else Double.NaN;
plot bot1 = if showClouds then -1 else Double.NaN;

# Strong Bull (0.7..1)
AddCloud(topStrong, top1, Color.DARK_GREEN, Color.DARK_GREEN);

# Mild Bull (0.15..0.7)
AddCloud(topMild, topStrong, Color.LIGHT_GREEN, Color.LIGHT_GREEN);

# Neutral (-0.15..0.15)
AddCloud(botMild, topMild, Color.LIGHT_GRAY, Color.LIGHT_GRAY);

# Mild Bear (-0.7..-0.15)
AddCloud(botStrong, botMild, Color.PINK, Color.PINK);

# Strong Bear (-1..-0.7)
AddCloud(bot1, botStrong, Color.DARK_RED, Color.DARK_RED);

# === Strong alignment cross detection ===
def prevSent = sentFinal[1];
def crossStrongUp = prevSent < strongThreshold and sentFinal >= strongThreshold;
def crossStrongDown = prevSent > -strongThreshold and sentFinal <= -strongThreshold;

# === Vertical lines ===
AddVerticalLine(crossStrongUp, "BUY", Color.GREEN, Curve.SHORT_DASH);
AddVerticalLine(crossStrongDown, "SELL", Color.RED, Curve.SHORT_DASH);

# === Label ===
AddLabel(yes, "Sentiment: " + Round(sentFinal, 2), if sentFinal > 0 then Color.GREEN else if sentFinal < 0 then Color.RED else Color.GRAY);
 
Last edited by a moderator:

Join useThinkScript to post your question to a community of 21,000+ developers and traders.

Been working on a “Sentiment Pulse Oscillator” for a little while now and just wanted to share it with everyone. This indicator combines RSI, CCI, and OBV into one normalized reading between –1 and +1. It smooths the result, shades the background into sentiment zones, and plots vertical lines labeled “BUY” or “SELL” whenever the sentiment crosses into strong bullish or bearish alignment. The idea is to simplify multiple indicators into a single clean view of momentum, trend, and volume pressure. It’s been working well in my testing, but I know it’s far from perfect, so I’d love any suggestions on how to make it more accurate, responsive, or visually clear.

KpGEnIf.png

Here is the code:
Ruby:
declare lower;

# === INPUTS ===
input rsiLength = 14;
input cciLength = 20;
input obvLength = 20;

input cciNormFactor = 200.0;  # Scale for CCI --> [-1,1]
input zScale = 3.0;  # number of stdevs to map OBV z-score

input wRSI = 1.0;
input wCCI = 1.0;
input wOBV = 1.0;

input smoothlength = 3; # Smoothing for final oscillator

input strongThreshold = 0.7;
input mildThreshold = 0.15;

input showcomponents = yes;
input showclouds = yes;

# === Raw component series ===
def rsi = RSI(rsilength);
def cci = CCI(CCIlength);
def obv = onbalancevolume();

# === RSI normalization: 0..100 -> -1..1 ===
def rsiNorm = (rsi - 50) /50;
def rsiNormClip = if rsinorm > 1 then 1 else if rsinorm < -1 then -1 else rsiNorm;

# === CCI normalization: divide by factor then clip ===
def ccinorm = cci / ccinormFactor;
def ccinormclip = if ccinorm > 1 then 1 else if ccinorm < -1 then -1 else ccinorm;

# === OBV normalization via z-score then scale & clip ===
def obvMean = Average(obv, obvlength);
def obvStDev = stdev(obv, obvlength);
def obvz = if obvstdev != 0 then (obv - obvmean) / obvstdev else 0;
def obvnorm = obvz / zscale;
def obvnormclip = if obvnorm > 1 then 1 else if obvnorm < -1 then -1 else obvnorm;

# === Combine with weights ===
def totalweight = wrsi + wcci + wobv;
def sentRaw = (wrsi * rsinormclip + wcci *ccinormclip + wobv * obvnormclip) / totalweight;

# === Smoothlength & final clip to [-1, 1] ===
def sent = expaverage(sentraw, smoothlength);
def sentfinal = if sent > 1 then 1 else if sent < -1 then -1 else sent;

# === Main plot ===
plot sentiment = sentfinal;
sentiment.setDefaultColor(color.cyan);
sentiment.setLineWeight(3);

# === Zero Line ===
plot zeroline = 0;
zeroline.setDefaultColor(color.white);
Zeroline.SetLineWeight(3);

# === Optional component plots ===
plot RSI_Line = if showComponents then rsiNormClip else Double.NaN;
RSI_Line.SetDefaultColor(Color.YELLOW);
RSI_Line.SetLineWeight(1);

plot CCI_Line = if showComponents then cciNormClip else Double.NaN;
CCI_Line.SetDefaultColor(Color.MAGENTA);
CCI_Line.SetLineWeight(1);

plot OBV_Line = if showComponents then obvNormClip else Double.NaN;
OBV_Line.SetDefaultColor(Color.RED);
OBV_Line.SetLineWeight(1);

# === Clouds (5 zones) ===
plot top1 = if showClouds then 1 else Double.NaN;
plot topStrong = if showClouds then strongThreshold else Double.NaN;
plot topMild = if showClouds then mildThreshold else Double.NaN;
plot botMild = if showClouds then -mildThreshold else Double.NaN;
plot botStrong = if showClouds then -strongThreshold else Double.NaN;
plot bot1 = if showClouds then -1 else Double.NaN;

# Strong Bull (0.7..1)
AddCloud(topStrong, top1, Color.DARK_GREEN, Color.DARK_GREEN);

# Mild Bull (0.15..0.7)
AddCloud(topMild, topStrong, Color.LIGHT_GREEN, Color.LIGHT_GREEN);

# Neutral (-0.15..0.15)
AddCloud(botMild, topMild, Color.LIGHT_GRAY, Color.LIGHT_GRAY);

# Mild Bear (-0.7..-0.15)
AddCloud(botStrong, botMild, Color.PINK, Color.PINK);

# Strong Bear (-1..-0.7)
AddCloud(bot1, botStrong, Color.DARK_RED, Color.DARK_RED);

# === Strong alignment cross detection ===
def prevSent = sentFinal[1];
def crossStrongUp = prevSent < strongThreshold and sentFinal >= strongThreshold;
def crossStrongDown = prevSent > -strongThreshold and sentFinal <= -strongThreshold;

# === Vertical lines ===
AddVerticalLine(crossStrongUp, "BUY", Color.GREEN, Curve.SHORT_DASH);
AddVerticalLine(crossStrongDown, "SELL", Color.RED, Curve.SHORT_DASH);

# === Label ===
AddLabel(yes, "Sentiment: " + Round(sentFinal, 2), if sentFinal > 0 then Color.GREEN else if sentFinal < 0 then Color.RED else Color.GRAY);
I made some small edits but i think this looks a little more clear, the CCI leads sentiment you want the RSI and OBV to support the move

1755820521068.png


Code:
declare lower;
# === INPUTS ===
input rsiLength = 14;
input cciLength = 20;
input obvLength = 20;

input cciNormFactor = 200.0; # Scale for CCI --> [-1,1]
input zScale = 3.0; # number of stdevs to map OBV z-score

input wRSI = 1.0;
input wCCI = 1.0;
input wOBV = 1.0;

input smoothlength = 3; # Smoothing for final oscillator

input strongThreshold = 0.7;
input mildThreshold = 0.15;

input showcomponents = yes;
input showclouds = yes;

# === Raw component series ===
def rsi = RSI(rsilength);
def cci = CCI(CCIlength);
def obv = onbalancevolume();

# === RSI normalization: 0..100 -> -1..1 ===
def rsiNorm = (rsi - 50) /50;
def rsiNormClip = if rsinorm > 1 then 1 else if rsinorm < -1 then -1 else rsiNorm;

# === CCI normalization: divide by factor then clip ===
def ccinorm = cci / ccinormFactor;
def ccinormclip = if ccinorm > 1 then 1 else if ccinorm < -1 then -1 else ccinorm;

# === OBV normalization via z-score then scale & clip ===
def obvMean = Average(obv, obvlength);
def obvStDev = stdev(obv, obvlength);
def obvz = if obvstdev != 0 then (obv - obvmean) / obvstdev else 0;
def obvnorm = obvz / zscale;
def obvnormclip = if obvnorm > 1 then 1 else if obvnorm < -1 then -1 else obvnorm;

# === Combine with weights ===
def totalweight = wrsi + wcci + wobv;
def sentRaw = (wrsi * rsinormclip + wcci *ccinormclip + wobv * obvnormclip) / totalweight;

# === Smoothlength & final clip to [-1, 1] ===
def sent = expaverage(sentraw, smoothlength);
def sentfinal = if sent > 1 then 1 else if sent < -1 then -1 else sent;

# === Main plot ===
plot sentiment = sentfinal;
sentiment.setDefaultColor(color.cyan);
sentiment.setLineWeight(3);

# === Zero Line ===
plot zeroline = 0;
zeroline.setDefaultColor(color.GRAY);
Zeroline.SetLineWeight(1);

# === Optional component plots ===
plot RSI_Line = if showComponents then rsiNormClip else Double.NaN;
RSI_Line.SetPaintingStrategy(PaintingStrategy.HISTOGRAM);
RSI_Line.SetDefaultColor(Color.YELLOW);
RSI_Line.SetLineWeight(1);

plot CCI_Line = if showComponents then cciNormClip else Double.NaN;
CCI_Line.SetDefaultColor(Color.MAGENTA);
CCI_Line.SetLineWeight(1);

plot OBV_Line = if showComponents then obvNormClip else Double.NaN;
OBV_Line.SetPaintingStrategy(PaintingStrategy.HISTOGRAM);
OBV_Line.SetDefaultColor(Color.RED);
OBV_Line.SetLineWeight(1);

# === Clouds (5 zones) ===
plot top1 = if showClouds then 1 else Double.NaN;
plot topStrong = if showClouds then strongThreshold else Double.NaN;
plot topMild = if showClouds then mildThreshold else Double.NaN;
plot botMild = if showClouds then -mildThreshold else Double.NaN;
plot botStrong = if showClouds then -strongThreshold else Double.NaN;
plot bot1 = if showClouds then -1 else Double.NaN;

# Strong Bull (0.7..1)
AddCloud(topStrong, top1, Color.DARK_GREEN, Color.DARK_GREEN);

# Mild Bull (0.15..0.7)
#AddCloud(topMild, topStrong, Color.LIGHT_GREEN, Color.LIGHT_GREEN);

# Neutral (-0.15..0.15)
#AddCloud(botMild, topMild, Color.LIGHT_GRAY, Color.LIGHT_GRAY);

# Mild Bear (-0.7..-0.15)
#AddCloud(botStrong, botMild, Color.PINK, Color.PINK);

# Strong Bear (-1..-0.7)
AddCloud(bot1, botStrong, Color.DARK_RED, Color.DARK_RED);

# === Strong alignment cross detection ===
def prevSent = sentFinal[1];
def crossStrongUp = prevSent < strongThreshold and sentFinal >= strongThreshold;
def crossStrongDown = prevSent > -strongThreshold and sentFinal <= -strongThreshold;

# === Vertical lines ===
AddVerticalLine(crossStrongUp, "BUY", Color.GREEN, Curve.SHORT_DASH);
AddVerticalLine(crossStrongDown, "SELL", Color.RED, Curve.SHORT_DASH);

# === Label ===
AddLabel(yes, "Sentiment: " + Round(sentFinal, 2), if sentFinal > 0 then Color.GREEN else if sentFinal < 0 then Color.RED else Color.GRAY);

AddCloud(CCI_Line, sentiment, Color.GREEN, Color.RED);
 
Last edited by a moderator:
Hey TraderSam, thank you for sharing - could you explain a little more regarding the colors and the lines - sentiment etc
 
Hey TraderSam, thank you for sharing - could you explain a little more regarding the colors and the lines - sentiment etc
The indicator takes RSI (measures momentum/overbought vs. oversold), CCI (shows how far price is stretched from its average, spotting strength or weakness), and OBV (tracks buying vs. selling pressure through volume), then normalizes them to the same -1 to +1 scale. Those three are blended into one “sentiment” line (cyan) against the zero line (white). The yellow line is RSI, the magenta line is CCI, and the red line is OBV, showing each indicator’s contribution. The background clouds give quick visual zones: dark/light green = bullish, gray = neutral, pink/red = bearish. When the cyan line crosses into the strong bullish or bearish zones, the script marks it with vertical “BUY” or “SELL” signals. I have just been using the "sentiment" line and the buy/sell vertical lines because I like less clutter on my chart. To turn off all the extra lines just go into the setting of the indicator and change "show components" to no. Hope this helps!
Screenshot 2025-08-22 at 10.20.45 PM.png
 
The indicator takes RSI (measures momentum/overbought vs. oversold), CCI (shows how far price is stretched from its average, spotting strength or weakness), and OBV (tracks buying vs. selling pressure through volume), then normalizes them to the same -1 to +1 scale. Those three are blended into one “sentiment” line (cyan) against the zero line (white). The yellow line is RSI, the magenta line is CCI, and the red line is OBV, showing each indicator’s contribution. The background clouds give quick visual zones: dark/light green = bullish, gray = neutral, pink/red = bearish. When the cyan line crosses into the strong bullish or bearish zones, the script marks it with vertical “BUY” or “SELL” signals. I have just been using the "sentiment" line and the buy/sell vertical lines because I like less clutter on my chart. To turn off all the extra lines just go into the setting of the indicator and change "show components" to no. Hope this helps!View attachment 25534
Thank you so much for your prompt response, that was a great description.
 
TRADERSAM, another question if you don't mind - Is this designed for a specific time frame?
If not what time frame do you use and why?
 
TRADERSAM, another question if you don't mind - Is this designed for a specific time frame?
If not what time frame do you use and why?
This indicator isn’t designed for a specific timeframe, but I primarily use it on the yearly chart since I focus on swing trading. However, I don’t rely on it alone; I use it mainly to confirm trades I’m planning to take.
 

Similar threads

Not the exact question you're looking for?

Start a new thread and receive assistance from our community.

87k+ Posts
332 Online
Create Post

Similar threads

Similar threads

The Market Trading Game Changer

Join 2,500+ subscribers inside the useThinkScript VIP Membership Club
  • Exclusive indicators
  • Proven strategies & setups
  • Private Discord community
  • ‘Buy The Dip’ signal alerts
  • Exclusive members-only content
  • Add-ons and resources
  • 1 full year of unlimited support

Frequently Asked Questions

What is useThinkScript?

useThinkScript is the #1 community of stock market investors using indicators and other tools to power their trading strategies. Traders of all skill levels use our forums to learn about scripting and indicators, help each other, and discover new ways to gain an edge in the markets.

How do I get started?

We get it. Our forum can be intimidating, if not overwhelming. With thousands of topics, tens of thousands of posts, our community has created an incredibly deep knowledge base for stock traders. No one can ever exhaust every resource provided on our site.

If you are new, or just looking for guidance, here are some helpful links to get you started.

What are the benefits of VIP Membership?
VIP members get exclusive access to these proven and tested premium indicators: Buy the Dip, Advanced Market Moves 2.0, Take Profit, and Volatility Trading Range. In addition, VIP members get access to over 50 VIP-only custom indicators, add-ons, and strategies, private VIP-only forums, private Discord channel to discuss trades and strategies in real-time, customer support, trade alerts, and much more. Learn all about VIP membership here.
How can I access the premium indicators?
To access the premium indicators, which are plug and play ready, sign up for VIP membership here.
Back
Top