#
# SMO_MktVolumesDaily.ts
# Version 4.0, 2022-07-4
# -- Enhanced by detecting undercut of prior rally day 1
# Version 3.0, 2022-06-25
# -- Fixed a couple of invalid signals for FTD's brought by market volatility
# For daily charts only.
# Nasdaq total volume: $TVOL/Q ; NYSE total volume: $TVOL
# Distribution day count tracking by IBD:
# https://www.investors.com/how-to-invest/investors-corner/
# tracking-distribution-days-a-crucial-habit/
# Stalling daysFromDate tracking by IBD:
# https://www.investors.com/how-to-invest/investors-corner/
# can-slim-market-tops-stalling-distribution/
declare lower;
declare zerobase;
def length = 20; # volume moving average lenth in days
input volumeSymbol = {default NYSE, NASDAQ, SPX};
# Reset distribution day counts on FTD.
input distributionRstDay = 20191010;
def volCl;
def volHi;
def findSymbol;
# To make volume differences more visible, use a base volume number
# The subtracted volume number is then magnified to present a bigger difference
def volMin; # base number for volume
def dropThreshold;
switch (volumeSymbol) {
# It was found there may be erratic volume data on close values
# On 2/19/2020, NYSE volume close values were 0 on 2/18 & 2/12
case NYSE:
volCl = if close("$TVOL") == 0 then high("$TVOL") else close("$TVOL");
#volCl = close("$TVOL");
volHi = high("$TVOL");
# use SPX volume change percentage to replace erratic NYSE volume
findSymbol = if volCl == 0 then volCl[1] * (1+ (close("$TVOLSPC") - close("$TVOLSPC")[1])/close("$TVOLSPC")[1]) else volCl;
volMin = 40000;
dropThreshold = .942;
case NASDAQ:
volcl = if close("$TVOL/Q") == 0 then high("$TVOL/Q") else close("$TVOL/Q");
#volCl = close("$TVOL/Q");
volHi = high("$TVOL/Q");
# use SPX volume change percentage to replace erratic NASDAQ volume
findSymbol = if volCl == 0 then volCl[1] * (1+ (close("$TVOLSPC") - close("$TVOLSPC")[1])/close("$TVOLSPC")[1]) else volCl;
volMin = 30000;
dropThreshold = .931;
case SPX:
volcl = if close("$TVOLSPC") == 0 then high("$TVOLSPC") else close("$TVOLSPC");
#volCl = close("$TVOLSPC");
volHi = high("$TVOLSPC");
# use NYSE volume change percentage to replace erratic SPX volume
findSymbol = if volCl == 0 then volCl[1] * (1+ (close("$TVOL") - close("$TVOL")[1])/close("$TVOL")[1]) else volCl;
volMin = 10000;
dropThreshold = .942;
#case Other:
# findSymbol = close; #No plots if volume() is used here!?
}
# It's possible to use if then else statements to automatically find proper
# volume symbol as described in
# https://tlc.thinkorswim.com/center/reference/thinkScript/Reserved-Words/if.html
# if GetSymbol() == "NYSE" then findSymbol = close("$TVOL") ...
def cls = close;
def lastBar = HighestAll(if (IsNaN(cls), Double.NaN, BarNumber()));
def volumes = if IsNaN(findSymbol) and BarNumber() == lastBar then volumes[1] else findSymbol;
plot Vol = 3 * (volumes - volMin);
#plot Vol = volumes;
plot VolAvg = 3 * (Average(volumes, length) - volMin);
Vol.SetPaintingStrategy(PaintingStrategy.HISTOGRAM);
Vol.SetLineWeight(3);
Vol.DefineColor("Up", Color.UPTICK);
Vol.DefineColor("Down", Color.DOWNTICK);
Vol.AssignValueColor(if cls > cls[1] then Vol.color("Up")
else if cls < cls[1] then Vol.color("Down")
else GetColor(1));
VolAvg.SetDefaultColor(GetColor(8));
# Display useful texts starting at upper left corner
# End of Day volume change
def VolChangePercentDay = if (IsNaN(volumes[1]), 0,
100 * (volumes - volumes[1])/volumes[1]);
# InvalidDay was added since volume on 2019/11/29 (after Thanksgiving) was N/A.
addLabel( yes, if volChangePercentDay == 0 then "InvalidDay" else "" +
"VolmChg="+ Concat("", round(VolChangePercentDay)) +
"%", if VolChangePercentDay < 0 then
Color.DARK_GRAY else if cls > cls[1] then Color.DARK_GREEN
else Color.DARK_RED);
# Count distributionDay only if market price drops 0.2% or more
def downDay = cls <= (cls[1] * 0.998);
def volIncrease = vol > vol[1];
#
# After 25 sessions, a distribution day expires
# Use 25 bar numbers to represent 25 live sessions. GetDay or alike includes weekends.
#
def lastDays = if (BarNumber() > lastBar - 25) then 1 else 0;
# a distribution day can fall off the count if the index rises 6% or more,
# on an intraday basis, from its close on the day the higher-volume loss appears.
# Remove distribution days after prices increases 6% WHEN market is in uptrend.
# Need to fix:
# During market bottomed on 2-28-2020, stock price rose to 9.8% with market still in
# correction. The high volume selloff on 2-28 would still be counted as a distribution.
# The highest date should be after the distribution day
# Get proper high for future 25 days
def prHi = high;
def prLo = low;
def futureHigh = if isNaN(prHi[-25]) then futureHigh[1] else prHi[-25];
def prHighest = Highest(futureHigh, 25);
# Note: This condition disqualifies D-Days after large bear rally
# This is acceptable for now since D-Days in bear market are not really useful
def priceInRange = (cls * 1.06 >= prHighest);
def distributionDay = downDay and volIncrease and LastDays and priceInRange;
# Count valid distribution days in last 25 days
def distDayCount = sum(distributionDay, 25);
# A broad market correction makes the distribution day count irrelevent
# reset distribution count to 0
# Distribution day count should reset after 2nd confirmation day
# To do: automate the reset day when correction or follow-up day appears
# input distributionRstDay = 20191010; a prior 2nd confirmation day
# input distributionRstDay = 20200402; a prior 2nd confirmation day
#input distributionRstDay = 20191010;
def newDistributionCycle = GetYYYYMMDD() > distributionRstDay;
# Need to use above variable to restart d-day count
def newDistDays = sum(distributionDay and newDistributionCycle, 25);
# Display bubble red is count > 5, yellow >3, else while
AddChartBubble(distributionDay and !newDistributionCycle, vol, concat("", distDayCount),
if distDayCount < 3 then color.WHITE
else if distDayCount < 5 then color.LIGHT_ORANGE
else color.RED);
# Show D-Day reset line at the reset date input by user
# It appears at the left side of the volume bar
AddVerticalLine( if (GetYYYYMMDD() == distributionRstDay ) then yes
else no,
" 2ndCnfm",
Color.GREEN, Curve.MEDIUM_DASH);
# to do: Comparison of preholiday data may be invalid.
#------------------------------------------------------------
# Stalling day counts
# 1. market has been rising and price is within 3% of 25 day high
# 2. Price making a high
# current close >= prior 2 day close, or
# current close >= prior day high
# 3. volume >= 95% of prior day volume
# 4. close in lower half of daily range
# 5. small gain within 0.4% for SPX & NASDAQ
# 6. The above IBD criteria disclosed in one article generates too many stalling days
# Additional rules from IBD book are used to further reduce stalling counts
# 6.1 close up smaller than prior 2 days
# 6.2 low is lower than high of prior day (No unfilled gap-up)
# 6.3 there is at least one decent gain in prior 2 days
# 6.4 daily trading range should be similar to last 2 days
# 7. stalling counts are reduced due to time (25 days) and significantly upward
# movement (6%) of the index
# Ex. 2019/11/12 was a stalling day on SPX, 2019/12/18 was stalling for Nasdaq
def priceIsHigh = cls >= cls[2] or cls >= prHi[1];
def priceLowHalf = cls < (prHi - prLo)/2 + prLo;
def priceGainSmall = cls - cls[1] > 0 and
((cls - cls[1] < (cls[1] - cls[2])) or
((cls - cls[1] < cls[2] - cls[3])));
# Added a 0.2% gap from prior day high to allow 2020/05/26 to count
# as a stalling day for NASDAQ
def priceGapFill = prLo < prHi[1] * 1.002;
def priceGainOk = (cls[1] - cls[2] > 0.002 * cls[2]) or
(cls[2] - cls[3] > 0.002 * cls[3]);
# price trading range is the high - low plus the gapup if any
def priceRange = if prLo > prHi[1] then prHi-prHi[1] else prHi -prLo;
def priceRangeBig = priceGainOk and priceRange > 0.8 * min(priceRange[1], priceRange[2]);
def stallDay = cls - cls[25] > 0 and
cls >= 0.97 * Highest( prHi, 25) and
volumes > 0.95 * volumes[1] and
cls - cls[1] > 0 and
cls - cls[1] < 1.004 * cls[1] and
priceIsHigh and priceLowHalf and priceGainSmall and priceGapFill and
priceRangeBig and lastDays;
# Count stalling days
def stallDayCount = sum(stallDay, 25);
# calculate new stalling days after the reset day (e.g. follow-up date)
def newStallDays = sum(StallDay and newDistributionCycle, 25);
# Display final distribution count (incl. stall days)
# red if >= 5, >3: yellow, else green
def totalDdays = distDayCount+stallDayCount;
def totalNdDays = newDistDays+newStallDays;
AddChartBubble(distributionDay and newDistributionCycle, vol,
if volCl == 0 then concat("?", newDistDays) else concat("", newDistDays),
if totalNdDays < 3 then color.WHITE
else if totalNdDays < 5 then color.LIGHT_ORANGE
else color.RED);
AddChartBubble(stallDay AND lastDays, vol,
if volCl == 0 then "?S" + concat("", stallDayCount)
else "S" + concat("", stallDayCount),
if totalNdDays < 3 then color.WHITE
else if totalNdDays < 5 then color.LIGHT_ORANGE
else color.RED);
AddChartBubble(volCl == 0 AND !stallDay AND !distributionDay, vol, "?", color.LIGHT_GRAY);
addLabel(totalDdays != totalNdDays, "AllDdays =" + concat("", totalDdays), Color.GRAY);
#-------------------------------------------------------------------------------------
# Follow-through signals (FTD) are more likely to fail if distribution days
# occur in the first few days of a new uptrend. This is one key red flag.
# Quantification in script is implemented with a concept of critical score (critScore):
# critScore = 3 for the 1st 5 days after FTD
# critScore = 2 on the 6th, critScore = 1 on 7th day, critScore = 0 after 7th day
# Total Distribution days = critScore + regular D-day count
# critScore is used only if there is at least one D-day in the 1st 5th day after FTD
#-------------------------------------------------------------------------------------
def ftdBar = if GetYYYYMMDD() == distributionRstDay then barnumber() else 0;
def lastFtdBar = highestall(ftdBar);
def daysAfterFTD = lastBar - lastFtdBar;
def critScore = if daysAfterFTD <= 0 then 0 else
if daysAfterFTD <= 5 then 3 else
if daysAfterFTD <= 6 then 2 else
if daysAfterFTD <= 7 then 1 else 0;
def totalNdDaysC = totalNdDays + if totalNdDays > 0 then critScore else 0;
# Actual distribution day count is shown but color depends on totalNdDaysC
addLabel(yes, "NewDdays =" + concat("",totalNdDays ),
if totalNdDaysC <=2 then Color.Green
else if totalNdDaysC <= 4 then Color.ORANGE
else Color.RED);
# Add an indication of 1st rally day to start FTD count
# in a market correction period
# pink rally day is a day satisfying the following conditions:
# 1). Close above ½ of daily TRUE range and below prior day close
# 2). Low is the lowest during the market correction,
# including future lows if available
# resolution of each 1st rally day is set to about 2 weeks
def rDayInterval = 5; #round(25/2, 0);
def futureLow = if isNaN(prLo[-rDayInterval]) then futureLow[1]
else prLo[-rDayInterval];
def futureCls = if isNaN(cls[-rDayInterval]) then futureCls[1]
else cls[-rDayInterval];
# market correction is currently defined as down about 8% from top
# need to be refined so that it will work in a bear market that is forming a bottom.
# In this case, the 8% drop may not be required.
def mktCr = prLo[1] <= highest(high, 25) * dropThreshold; #.931; #.92;
def prRng = TrueRange(prHi, cls, prLo); #prHi - prLo;
def pinkRday = cls > (prLo + prRng/2) and cls < cls[1] and
prLo <= lowest(prLo[1],rDayInterval) and
prLo <= lowest(futureLow, rDayInterval);
# The real rally day has its close higher than prior close
# A rally day is invalidated if the low is broken in subsequent days
def realRday = cls > cls[1] and
(prLo <= lowest(prLo[1],rDayInterval) or
prLo[1] <= lowest(prLo[1],rDayInterval)) and
(prLo <= lowest(futureLow, rDayInterval) or
prLo[1] <= lowest(futureLow, rDayInterval)) and
sum(realRday[1], rDayInterval) == 0 and
sum(pinkRday[1], rDayInterval) == 0;
def RallyDay1 = (mktCr or mktCr[1]) and (pinkRday or realRday);
# Have to detect if current price low has undercut the low of prior R1 day
# RallyDay1 is still active only if prices have not undercut low of the following:
# 1. the low on that day if pink rally
# 2. the lower value between the low on that day and the low before that day if real rally
def pinkLow = if !pinkRDay then pinkLow[1] else
if pinkLow[1] != 0 then min(prLo, pinkLow[1]) else prLo;
def prLo2Days = min(prLo, prLo[1]);
def realLow = if !realRday then realLow[1] else
if prLo2Days < realLow[1] then prLo2Days else
if realLow[1] == 0 then prLo2Days else realLow[1];
def rallyLow = if pinkLow == 0 then realLow else
if pinkLow < realLow then pinkLow else
if realLow == 0 then prLo else realLow;
def underRallyLow = if rallyLow[1] != 0 then prLo2Days < rallyLow[1] else
if prLo < prLo[1] then 1 else 0;
# require undercut only if the R1 day is not the 1st R1
def underCut = if sum(RallyDay1[1], 25) == 0 and prLo2Days < lowest(prLo[1], 25)
then 1 else underRallyLow;
def RallyDay1Cut = RallyDay1 and underCut;
# Currently (April, 2020) a daily price (cls) increase of 1.25% minimum is
# the price requirement by either SPX or NASDAQ for a FTD.
# Must be day 4 after 1st rally attempt /w an exception (1st 3 days are strong).
# Must have higher volume on the FTD day
def lastR1Bar = if (RallyDay1Cut, barnumber(), lastR1Bar[1]);
def daysAfterR1 = barnumber() - lastR1Bar;
# Show the last FTD only
def isFTD = if daysAfterR1 >= 3 and daysAfterR1 < 20
and barnumber() > lastR1Bar and
barnumber() < lastR1Bar + 25 and lastR1Bar != 0 and
cls >= (cls[1] * 1.0125) and
volIncrease then 1 else 0 ;
def ftdBarNum = if (isFTD, barnumber(), ftdBarNum[1]);
# Is it 1st FTD after latest R1 Day?
def ftd1AfterR1d = if ftdBarNum - ftdBarNum[1] > ftdBarNum - lastR1Bar then 1 else 0;
# Show FTD only if it's 14 days after the previous one
AddChartBubble(oneFTD, vol, "FTD", color.LIGHT_GREEN);
AddChartBubble(RallyDay1 and underCut, vol, "R1", color.LIGHT_GREEN);
# To do:
# IBD's book: "The Successful Investor"
# If most of the 3 to 5 days of distributions have small spreads from high to low
# the distribution is not large enough to cause market turning down.
# Significant distributions should have the spreads a little wider than average.
#
# All of the 3 volume data sets were smaller than actual numbers on 10-21-2020.
# The open/close/high/low values were in good relation.
# The intraday volume showed missing data starting around lunch time.