Despite these troubling times brought upon us by Covid-19, one interesting investment theme has outperformed the market - “stay-at-home” stocks. These generally consist of companies that would benefit from the lockdown such as big retailers with delivery services, tech companies providing essential software for WFH setups, and video gaming companies that benefit from bored kids and adults. These companies have outperformed the broader indices significantly and, intuitively, sentiment for these companies should be positive, right?

As a continuation of my previous post, I wanted to explore text mining on company’s earnings call transcripts. For US companies, these are conducted quarterly and are helpful in providing an understanding of business strategy, opportunities and headwinds it might face in the coming quarters - prefaced by management’s prepared remarks and guidance, followed by a Q&A session with analysts.

In this post I’ll showcase the following:

  • Visualizing sentiment shifts over time on a sector and company level
  • Identifying important company-specific words used
  • Observing sentiment change as a earnings call progresses

Data

For our data, we use quarterly earnings call transcripts from four internet companies from 1Q 2015 to 1Q 2020 inclusive. Sea Limited only went public in Oct 2017 so transcripts would only be available after then. I would also caveat that these companies may not be directly comparable due to diversity in revenue streams as well as different geographic regions - but I chose them on the basis that a significant portion of their revenue comes from internet-related services such as gaming and e-commerce.

Because these earnings call transcripts were extracted from Bloomberg, I’m unable to share them due to copyright issues although I hope the analysis would still be helpful for the reader.

Disclaimer

All data that I have used in this presentation is obtained from Bloomberg and I do not own any of it. Nothing in this document should be construed as investment advice, recommendations, an investment strategy, or whether or not to “buy”, “sell” or “hold” an investment, nor should it be construed or relied upon as tax, regulatory or accounting advice.

Libraries

We load the libraries necessary for our workflow.

library(pdftools) #merging and extracting text from pdfs
library(tidyverse) #data wrangling
library(lubridate) #extracting dates
library(tidytext) #text mining
library(fishualize) #visualization

theme_set(theme_minimal())

Merging PDFs

We merge the pdfs into one using pdf_combine()

pdf_combine(
  c("ATVI.pdf", "Netease.pdf", "SEA.pdf", "Tencent.pdf"),
    output = "merged.pdf")

Data Extraction And Cleaning

We begin by extracting text from the merged pdf transcript, labeling it then tokenizing the text so we can analyze words as individual units (unigram). pdf_data() was a super helpful function that did an incredible job extracting tidy data out of the pdf.

I did some further wrangling with purrr’s map() function to extract dates and company’s name for each of the sections.

df <- pdf_data("merged.pdf") %>%
  #select text column
  map(select, text) %>%
  map( ~ mutate(
    .,
    #extract date
    date = ymd(str_extract(., "\\d+\\-\\d+\\-\\d+")),
    #extract company's name
    company = word(.$text[3])
  )) %>%
  #merge all lists together
  reduce(bind_rows) %>%
  #tokenize words by unigrams
  unnest_tokens(word, text) 

df
## # A tibble: 710,300 x 3
##    date       company    word      
##    <date>     <chr>      <chr>     
##  1 2020-05-05 Activision company   
##  2 2020-05-05 Activision name      
##  3 2020-05-05 Activision activision
##  4 2020-05-05 Activision blizzard  
##  5 2020-05-05 Activision company   
##  6 2020-05-05 Activision ticker    
##  7 2020-05-05 Activision atvi      
##  8 2020-05-05 Activision us        
##  9 2020-05-05 Activision date      
## 10 2020-05-05 Activision 2020      
## # ... with 710,290 more rows

Checking if data was labeled correctly, we can see there are different naming conventions for NetEase over the years. Let’s standardize them.

df %>% 
  distinct(company)
## # A tibble: 5 x 1
##   company   
##   <chr>     
## 1 Activision
## 2 Netease   
## 3 NetEase   
## 4 Sea       
## 5 Tencent
df$company[df$company == "Netease"] <- "NetEase"

df %>% 
  distinct(company)
## # A tibble: 4 x 1
##   company   
##   <chr>     
## 1 Activision
## 2 NetEase   
## 3 Sea       
## 4 Tencent

Now to check if dates are mapped correctly. Quarterly calls are done with a 3 weeks lag i.e. 1Q 2015 results will take place in 2Q 2015. Since our data starts from 1Q15, we should expect 3 calls in 2015, 4 calls from 2016-2019, and 2 calls in 2020 (covering 4Q19 and 1Q20). The exception would be Sea, which only went public in late 2017.

df %>% 
  group_by(company) %>% 
  distinct(date, .keep_all = T) %>% 
  mutate(year = year(date)) %>%
  count(company, year) %>% 
  filter(!n == 4) %>% 
  arrange(year)
## # A tibble: 8 x 3
## # Groups:   company [4]
##   company     year     n
##   <chr>      <dbl> <int>
## 1 Activision  2015     3
## 2 NetEase     2015     3
## 3 Tencent     2015     3
## 4 Sea         2017     1
## 5 Activision  2020     2
## 6 NetEase     2020     2
## 7 Sea         2020     2
## 8 Tencent     2020     2

Removing Stop Words And Numbers

Next, we remove stop words (words that are not useful for an analysis, typically extremely common words such as the, of, to, and so forth in English) and numbers used during the earnings call.

df_tidy <- df %>%
  #remove stop words
  anti_join(stop_words, by = "word") %>%
  #remove numbers (digits)
  filter(!str_detect(word, c("\\d+"))) 

df_tidy
## # A tibble: 303,908 x 3
##    date       company    word       
##    <date>     <chr>      <chr>      
##  1 2020-05-05 Activision company    
##  2 2020-05-05 Activision activision 
##  3 2020-05-05 Activision blizzard   
##  4 2020-05-05 Activision company    
##  5 2020-05-05 Activision ticker     
##  6 2020-05-05 Activision atvi       
##  7 2020-05-05 Activision date       
##  8 2020-05-05 Activision event      
##  9 2020-05-05 Activision description
## 10 2020-05-05 Activision earnings   
## # ... with 303,898 more rows

Sentiment Analysis In A Finance Context

To extract sentiment for our purposes, we use the Loughran and McDonald (2011) financial lexicon - a widely used financial dictionary for NLP analysis due to its accessibility, its comprehensiveness, and its financial-specific context. As stressed in Loughran and Mcdonald (2011):

"Almost three-fourths of the words identified as negative by the widely used Harvard Dictionary are words typically not considered negative in financial contexts.

What they have done was to filter their initial master word list down to their sentiment word lists by examining 10-K filings between 1994 and 2008 inclusively. More information can be found in Section 4.1 of S&P Global’s NLP Primer.

df_tidy <- df_tidy %>% 
  inner_join(get_sentiments("loughran"), by = "word") %>% 
  #counting number of sentiment words 
  add_count(company, name = "senti_n")

df_tidy %>% 
  select(date, company, word, sentiment) %>% 
  sample_n(10)
## # A tibble: 10 x 4
##    date       company    word         sentiment  
##    <date>     <chr>      <chr>        <chr>      
##  1 2018-11-08 Activision benefit      positive   
##  2 2019-05-22 Sea        strong       positive   
##  3 2019-11-13 Tencent    unidentified uncertainty
##  4 2020-05-05 Activision persist      negative   
##  5 2017-05-17 Tencent    regulators   litigious  
##  6 2016-08-17 Tencent    benefit      positive   
##  7 2017-08-16 Tencent    benefit      positive   
##  8 2015-11-10 Tencent    declines     negative   
##  9 2019-05-02 Activision disruptions  negative   
## 10 2020-05-18 Sea        crisis       negative

Sentiment Analysis Over Time: Sector

With stock prices of these internet stocks up anywhere between 10% to 96% since the beginning of the year, intuitively, we would expect negative sentiment to be low, right?

text <- data.frame(
  label = c("+19% YTD", "+23% YTD", "+96% YTD", "13% YTD"),
  company = c("Activision", "NetEase", "Sea", "Tencent"),
  x = c(as.Date("2020-04-01"), as.Date("2020-04-01"), as.Date("2020-04-01"), as.Date("2020-04-01")),
  y = c(185, 125, 150, 200)
)

df_tidy %>%
  filter(sentiment %in% c("negative", "uncertainty", "constraining")) %>%
  ungroup() %>%
  mutate(date = floor_date(date, unit = "quarter")) %>%
  ggplot(aes(date)) +
  geom_bar(aes(fill = sentiment), position = "stack") +
  facet_wrap(~ company, scales = "free_x") +
  scale_x_date(date_breaks = "12 months", date_labels = "%Y") +
  scale_fill_fish_d(option = "Antennarius_commerson") +
  geom_text(data = text,
            mapping = aes(x = x, y = y, label = label)) +
  labs(
    x = "Date",
    y = "Count",
    fill = "Sentiment",
    title = "1Q20 Negative Sentiment Across All Four Internet Companies Worsened",
    subtitle = "Contrast this against spectacular 2020 year-to-date (YTD) share price gains",
    caption = "Source: Quarterly earnings call transcripts since 1Q 2015\nNote: Sea Ltd only went public in Oct 2017"
  ) +
  theme(
    plot.title = element_text(face = "bold", size = 20),
    plot.title.position = "plot",
    plot.subtitle = element_text(size = 15),
    strip.background = element_blank(),
    strip.text = element_text(face = "bold", size = 15),
    legend.title = element_text(face = "bold"),
    legend.position = "top"
  ) 

df_tidy %>% 
  group_by(company) %>% 
  distinct(date, .keep_all = TRUE) %>% 
  filter(date > "2020-01-01")
## # A tibble: 8 x 5
## # Groups:   company [4]
##   date       company    word         sentiment   senti_n
##   <date>     <chr>      <chr>        <chr>         <int>
## 1 2020-05-05 Activision opportunity  positive       4940
## 2 2020-02-06 Activision incorrect    negative       4940
## 3 2020-05-20 NetEase    litigation   negative       3505
## 4 2020-02-27 NetEase    litigation   negative       3505
## 5 2020-05-18 Sea        opportunity  positive       2777
## 6 2020-03-03 Sea        opportunity  positive       2777
## 7 2020-05-13 Tencent    question     negative       5507
## 8 2020-03-18 Tencent    unidentified uncertainty    5507

Our negative sentiment signal seems to contradict the percentage gains that share prices have made this year. Or does it? Let’s explore the positive sentiment signal.

df_tidy %>%
  filter(sentiment %in% c("positive")) %>%
  ungroup() %>%
  mutate(date = floor_date(date, unit = "quarter")) %>%
  ggplot(aes(date, fill = company)) +
  geom_bar(position = "stack", show.legend = FALSE) +
  facet_wrap(~ company, scales = "free_x") +
  scale_x_date(date_breaks = "12 months", date_labels = "%Y") +
  scale_fill_fish_d(option = "Antennarius_commerson") +
  labs(
    x = "Date",
    y = "Count",
    fill = "Sentiment",
    title = "1Q20 Positive Sentiment Appears Mixed With No Clear Signal",
    subtitle = "Sea, with 96% YTD share price gains (as of time of writing), had a surprising drop in positive sentiment",
    caption = "Source: Quarterly earnings call transcripts since 1Q 2015\nNote: Sea Ltd only went public in Oct 2017"
  ) +
  theme(
    plot.title = element_text(face = "bold", size = 20),
    plot.title.position = "plot",
    plot.subtitle = element_text(size = 15),
    strip.background = element_blank(),
    strip.text = element_text(face = "bold", size = 15),
    legend.title = element_text(face = "bold"),
    legend.position = "top"
  ) 

Sentiment Analysis Over Time: Company

Using the Loughran lexicon, we can identify the most common words driving sentiment. Here we use Sea’s transcripts since it went public.

df_tidy %>%
  filter(company == "Sea") %>%
  count(word, sentiment) %>%
  group_by(sentiment) %>%
  top_n(10) %>%
  ungroup() %>%
  mutate(word = reorder_within(word, n, sentiment)) %>%
  ggplot(aes(word, n, fill = sentiment)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  facet_wrap(
    ~ fct_relevel(sentiment, "positive", "negative", "litigious", "uncertainty", "constraining"),
    scales = "free",
    labeller = as_labeller(c("positive" = "Positive", 
                             "negative" = "Negative", 
                             "litigious" = "Litigious", 
                             "uncertainty" = "Uncertainty", 
                             "constraining" = "Constraining",
                             "superfluous" = "Superfluous"))) +
  scale_x_reordered() +
  scale_fill_fish_d(option = "Antennarius_commerson") +
  labs(
    x = "",
    y = "Count",
    fill = "Sentiment",
    title = "Since Sea Went Public, What Words Have Been Driving Sentiment?",
    subtitle = "The Loughran lexicon divides words into sentiments: 'positive', 'negative', 'litigious', 'uncertainty', 'constraining'",
    caption = "Source: Quarterly earnings call transcripts"
  ) +
  theme(
    plot.title = element_text(face = "bold", size = 20),
    plot.title.position = "plot",
    plot.subtitle = element_text(size = 15),
    strip.background = element_blank(),
    strip.text = element_text(face = "bold", size = 15),
    legend.title = element_text(face = "bold"),
    legend.position = "bottom"
  ) 

We can also compare transcripts in 2019 against transcripts in 2020 to potentially identify shifts in sentiment across time. One observation worth exploring is the ratio of negative to positive words over comparative time periods.

library(patchwork)

p1 <- df_tidy %>%
  filter(company == "Sea",
         date > "2019-01-01" & date < "2020-01-01",
         !sentiment %in% c("litigious", "superfluous")) %>%
  count(word, sentiment) %>%
  group_by(sentiment) %>%
  top_n(10) %>%
  ungroup() %>%
  mutate(word = reorder_within(word, n, sentiment)) %>%
  ggplot(aes(word, n, fill = sentiment)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  facet_wrap(
    ~ fct_relevel(sentiment, "positive", "negative", "litigious", "uncertainty", "constraining"),
    scales = "free",
    labeller = as_labeller(
      c("positive" = "Positive",
        "negative" = "Negative",
        "litigious" = "Litigious",
        "uncertainty" = "Uncertainty",
        "constraining" = "Constraining",
        "superfluous" = "Superfluous")
    )) +
  scale_x_reordered() +
  scale_fill_fish_d(option = "Antennarius_commerson") +
  labs(x = "",
       y = "Count",
       fill = "Sentiment",
       title = "2019") +
  theme(
    plot.title = element_text(face = "bold", size = 12),
    plot.title.position = "plot",
    plot.subtitle = element_text(size = 12),
    strip.background = element_blank(),
    strip.text = element_text(face = "bold", size = 12),
    legend.title = element_text(face = "bold"),
    legend.position = "bottom"
  ) 

p2 <- df_tidy %>%
  filter(company == "Sea",
         date > "2020-01-01",
         !sentiment %in% c("litigious", "superfluous")) %>%
  count(word, sentiment) %>%
  group_by(sentiment) %>%
  top_n(10) %>%
  ungroup() %>%
  mutate(word = reorder_within(word, n, sentiment)) %>%
  ggplot(aes(word, n, fill = sentiment)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  facet_wrap(
    ~ fct_relevel(sentiment, "positive", "negative", "litigious", "uncertainty", "constraining"),
    scales = "free",
    labeller = as_labeller(
      c("positive" = "Positive",
        "negative" = "Negative",
        "litigious" = "Litigious",
        "uncertainty" = "Uncertainty",
        "constraining" = "Constraining",
        "superfluous" = "Superfluous")
    )) +
  scale_x_reordered() +
  scale_fill_fish_d(option = "Antennarius_commerson") +
  labs(x = "",
       y = "Count",
       fill = "Sentiment",
       title = "2020 YTD") +
  theme(
    plot.title = element_text(face = "bold", size = 12),
    plot.title.position = "plot",
    plot.subtitle = element_text(size = 12),
    strip.background = element_blank(),
    strip.text = element_text(face = "bold", size = 12),
    legend.title = element_text(face = "bold"),
    legend.position = "bottom"
  ) 

p1 + p2 + 
  plot_annotation(
  title = "Exploring Changes Over Time In Sentiment: Sea (2019 Versus 2020)",
  subtitle = "Words like 'leadership' and 'opportunity/opportunities' continue to dominate positive sentiment;\n'Volatility' has crept up to become the most used word framing uncertainty in 2020 thus far",
  theme = theme(plot.title = element_text(face = "bold", size = 18),
                plot.subtitle = element_text(size = 12))) +
  plot_layout(guides = "collect")

Company-Specific Word Importance

As a refresher, to gauge sentiment, we have been using the highest count or term frequency of a word. Another measure would be to incorporate a word’s inverse document frequency which applies a downward adjustment towards a word’s importance if said word occurs frequently across documents.

Combining both, we get term frequency-inverse document frequency or tf-idf: the frequency of a term adjusted for how rarely it is used. More reading can be found here.

Sorting by the words with the highest tf, we see words like the and to are common across all company’s transcripts, with a corresponding tf-idf of zero. Here we filter out numbers but don’t filter out any stop words as certain stop words could have a meaningful TF-IDF score.

#total times each word appears for each company
df_count <- df %>% 
  count(company, word, sort = TRUE)

#total number of words across all companies
total <- df_count %>% 
  group_by(company) %>% 
  summarise(total = sum(n))

df_count <- df_count %>% 
  left_join(total)

df_tf_idf <- df_count %>%
 filter(!str_detect(word, c("\\d+"))) %>% 
  bind_tf_idf(word, company, n)

df_tf_idf %>% 
  arrange(desc(tf))
## # A tibble: 20,396 x 7
##    company    word      n  total     tf   idf tf_idf
##    <chr>      <chr> <int>  <int>  <dbl> <dbl>  <dbl>
##  1 NetEase    the    6092 135569 0.0473     0      0
##  2 Sea        the    4812 112882 0.0441     0      0
##  3 Activision the    8762 213379 0.0429     0      0
##  4 Tencent    the    9694 248470 0.0408     0      0
##  5 Activision and    7809 213379 0.0382     0      0
##  6 NetEase    and    4413 135569 0.0343     0      0
##  7 Activision of     6729 213379 0.0329     0      0
##  8 Tencent    and    7419 248470 0.0312     0      0
##  9 Sea        and    3279 112882 0.0301     0      0
## 10 Sea        of     3251 112882 0.0298     0      0
## # ... with 20,386 more rows

We create a custom list of stop words to filter out names.

custom <- df_tf_idf %>%
  arrange(desc(tf_idf)) %>%
  group_by(company) %>%
  top_n(50) %>%
  ungroup() %>% 
  filter(!word %in% c("candy", "blizzcon", "hearthstone", "kaola", "youdao", "yanxuan", "commerce", "westward", "shopee", "indonesia", "fire", "ebitda", "sellers", "gmv", "qq", "weixin", "hk", "mini", "fintech", "moments", "browsers")) %>% 
  select(word)

Using the custom list of stop words and sorting by the highest tf-idf instead, we can visualize the highest tf-idf words across companies for the last five years.

df_tf_idf %>%
  select(-total) %>%
  arrange(desc(tf_idf)) %>%
  group_by(company) %>%
  top_n(60) %>%
  ungroup() %>%
  anti_join(custom) %>%
  mutate(word = reorder_within(word, tf_idf, company)) %>%
  ggplot(aes(word, tf_idf, fill = company)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  facet_wrap( ~ company, ncol = 2, scales = "free") +
  scale_x_reordered() +
  scale_fill_fish_d(option = "Antennarius_commerson") +
  labs(
    y = "",
    fill = "Sentiment",
    title = "How Do We Quantify The Importance Of Words?",
    subtitle = "Using tf-idf and a custom list of stop words, we're able to identify words related to a core strategic focus for the respective companies",
    caption = "Source: Quarterly earnings call transcripts since 1Q 2015"
  ) +
  theme(
    plot.title = element_text(face = "bold", size = 20),
    plot.title.position = "plot",
    plot.subtitle = element_text(size = 15),
    strip.background = element_blank(),
    strip.text = element_text(face = "bold", size = 15),
    legend.title = element_text(face = "bold"),
    legend.position = "bottom"
  )

An alternative visualization would be using a word cloud.

library(ggwordcloud)

df_tf_idf %>%
  select(-total) %>%
  arrange(desc(tf_idf)) %>%
  group_by(company) %>%
  top_n(120) %>%
  ungroup() %>%
  anti_join(custom) %>% 
  ggplot(aes(label = word,
             size = tf_idf,
             colour = company)) +
  geom_text_wordcloud_area(
    rm_outside = T,
    eccentricity = 0.65,
    shape = "circle"
  ) +
  scale_size_area(max_size = 40) +
  facet_wrap(. ~ company, nrow = 2) +
  scale_color_fish_d(option = "Antennarius_commerson") +
  labs(
    x = "",
    y = "",
    title = "An Alternative Way to Visualize Text Data: Word Clouds",
    subtitle = "Size of the word is an indication of its tf-idf importance",
    caption = "Source: Quarterly earnings call transcripts since 1Q 2015"
  ) +
  theme(
    plot.title = element_text(face = "bold", size = 20),
    plot.title.position = "plot",
    plot.subtitle = element_text(size = 15),
    strip.background = element_blank(),
    strip.text = element_text(face = "bold", size = 15),
    legend.title = element_text(face = "bold"),
    legend.position = "top"
  )

Sentiment Change Within Each Earnings Call

The structure of each earnings call is one that is prefaced by management’s prepared remarks and guidance, which are generally optimistic, followed by a Q&A session with analysts.

We can divide the earnings call into four equal parts to track sentiment levels as the call takes place e.g. sentiment changes during prepared remarks (first quantile) and the analyst Q&A.

#df before stop words and digits are removed
#Break up the earnings call into four quartiles.
df_sorted <- df %>%
  group_by(date, company) %>%
  mutate(linenumber = row_number(),
         total_words = length(linenumber)) %>%
  mutate(percent = linenumber / total_words) %>%
  group_by(date, company) %>%
  mutate(quantile = case_when(
    percent < 0.25 ~ 1,
    between(percent, 0.25, 0.5) ~ 2,
    between(percent, 0.5, 0.75) ~ 3,
    TRUE ~ 4
  )) %>% 
  ungroup() %>% 
  #remove stop words
  anti_join(stop_words, by = "word") %>%
  #remove numbers (digits)
  filter(!str_detect(word, c("\\d+"))) %>% 
  inner_join(get_sentiments("loughran"), by = "word") %>% 
  add_count(company, name = "senti_n") %>% 
  select(-4:-6)

For the purposes of visualization, we group ‘negative’, ‘litigious’, ‘uncertainty’, and ‘constraining’ sentiments together as one category - ‘negative’.

df_sorted %>%
  filter(company == "Sea",
         date > "2018-01-01",
         !sentiment %in% c("superfluous")) %>%
  mutate(
    sentiment2 = fct_collapse(
      sentiment,
      positive = "positive",
      negative = c("negative", "constraining", "uncertainty", "litigious")
    ),
    sentiment2 = fct_relevel(sentiment2, "positive", "negative")
  ) %>%
  ggplot(aes(quantile, fill = sentiment2)) +
  geom_bar(position = "dodge", width = 0.7) +
  facet_wrap( ~ date, scales = "free_x", nrow = 3) +
  scale_fill_fish_d(option = "Antennarius_commerson") +
  labs(
    x = "Call Duration (divided equally into four parts)",
    y = "",
    fill = "Sentiment",
    title = "Tracking Sentiment Changes Within Each Earnings Call (Sea)",
    subtitle = "Beginning portion of earnings calls contain prepared remarks and generally reflect a higher positive sentiment",
    caption = "Source: Quarterly earnings call transcripts"
  ) +
  theme(
    plot.title = element_text(face = "bold", size = 20),
    plot.title.position = "plot",
    plot.subtitle = element_text(size = 15),
    strip.background = element_blank(),
    strip.text = element_text(face = "bold", size = 15),
    legend.title = element_text(face = "bold"),
    legend.position = "top"
  )

Final Thoughts

I’ll wrap up with this post by citing one of my favourite memos from Howard Marks, where he recalls a lunch he had with Charlie Munger:

As it ended and I got up to go, he said something about investing that I keep going back to: “It’s not supposed to be easy. Anyone who finds it easy is stupid.”

There are a number of reasons why we shouldn’t expect a straightforward correlation (be it positive or negative) between sentiment and share price. In the first chart we saw negative sentiment trending upwards despite a surge in share prices, which may seem counter-intuitive but if you think about it - if everything was straightforward and intuitive, making investing an easy decision-making progress - no one would ever incur losses, which we know isn’t the case.

My thoughts as to why the relationship between sentiment and share prices are trickier than it seems:

  • Share prices are driven by other factors and running ahead of sentiment This can be a function of several factors not captured by sentiment extracted from earnings calls, including technical factors e.g. retail or institutional fund flows or general euphoria in the markets e.g. Trump tweets
  • Sentiment from earnings call transcripts alone may not be sufficient to capture the full picture. This can be a function of time lag e.g. events happening after the earnings call took place, or that the sentiment captured from management and analysts are not a reflection of the broader market sentiment. One possible way to address the latter is to increase our sample size of companies, and exploring other sources of unstructured data such as news or Twitter where sentiment can also be extracted from
  • Sentiment signal captured may be wrong e.g. questions asked were directed at competitors, negative words captured were double negatives, in which case using bigrams could be better served, or sentiment extracted were taken out of context due to language nuances.

Does that mean sentiment analysis in a finance context is useless? Hardly so. The examples I’ve shown above are interesting applications for inference and, applied on an adequate sample size over an appropriate time horizon, could serve as a helpful signal amidst all the noise in financial data. However, my sense is that some feature pre-processing is needed if sentiment signals were to be used with the intention of forecasting. A timely reminder that investing shouldn’t be easy.