1 Project purpose

This project builds reproducible investment and class-survey datasets from several external sources. Massive supplies the main daily stock-price data, Alpha Vantage supplies company fundamentals, FRED supplies macroeconomic indicators, and rvest collects finance headlines. The report cleans and combines these sources, calculates investment measures, creates charts, and exports CSV files for the group.

2 Setup

required_packages <- c(
  "httr", "jsonlite", "dplyr", "tidyr", "purrr", "readr",
  "lubridate", "ggplot2", "quantmod", "rvest", "stringr",
  "tidytext", "scales", "knitr", "zoo", "readxl"
)

missing_packages <- required_packages[
  !required_packages %in% rownames(installed.packages())
]

if (length(missing_packages) > 0) {
  install.packages(missing_packages, repos = "https://cloud.r-project.org")
}

invisible(lapply(required_packages, library, character.only = TRUE))

knitr::opts_chunk$set(
  echo = TRUE,
  message = FALSE,
  warning = FALSE,
  fig.width = 10,
  fig.height = 6
)

dir.create("output", showWarnings = FALSE)
dir.create("figures", showWarnings = FALSE)

massive_key <- Sys.getenv("MASSIVE_API_KEY")
alpha_key <- Sys.getenv("ALPHAVANTAGE_API_KEY")

if (!nzchar(massive_key)) {
  message("MASSIVE_API_KEY is missing. Yahoo Finance will be used as a fallback.")
}

if (!nzchar(alpha_key)) {
  message("ALPHAVANTAGE_API_KEY is missing. Fundamentals will be skipped.")
}
ticker_table <- tibble::tribble(
  ~ticker, ~company_name, ~asset_type, ~portfolio_weight,
  "AAPL", "Apple", "Stock", 0.20,
  "MSFT", "Microsoft", "Stock", 0.20,
  "NVDA", "NVIDIA", "Stock", 0.20,
  "AMZN", "Amazon", "Stock", 0.15,
  "JPM", "JPMorgan Chase", "Stock", 0.10,
  "SPY", "SPDR S&P 500 ETF", "ETF", 0.15
)

tickers <- ticker_table$ticker

end_date <- Sys.Date() - 1
start_date <- end_date - lubridate::years(2) + lubridate::days(7)
risk_free_rate <- 0.04
initial_investment <- 10000

ticker_table %>%
  knitr::kable(caption = "Selected investments and portfolio weights")
Selected investments and portfolio weights
ticker company_name asset_type portfolio_weight
AAPL Apple Stock 0.20
MSFT Microsoft Stock 0.20
NVDA NVIDIA Stock 0.20
AMZN Amazon Stock 0.15
JPM JPMorgan Chase Stock 0.10
SPY SPDR S&P 500 ETF ETF 0.15

3 Massive API stock data

The function below sends an HTTP GET request to Massive’s daily aggregate endpoint. It checks the HTTP status, checks the API response, converts timestamps to dates, and returns a consistent table.

get_massive_stock <- function(ticker, from_date, to_date, api_key) {
  if (!nzchar(api_key)) {
    stop("MASSIVE_API_KEY is not available.")
  }

  endpoint <- paste0(
    "https://api.massive.com/v2/aggs/ticker/", ticker,
    "/range/1/day/", from_date, "/", to_date
  )

  response <- httr::GET(
    endpoint,
    query = list(
      adjusted = "true",
      sort = "asc",
      limit = 50000,
      apiKey = api_key
    ),
    httr::timeout(30)
  )

  httr::stop_for_status(response)
  parsed <- jsonlite::fromJSON(
    httr::content(response, "text", encoding = "UTF-8"),
    simplifyDataFrame = TRUE
  )

  if (!is.null(parsed$error)) stop(parsed$error)
  if (!is.null(parsed$message) && is.null(parsed$results)) stop(parsed$message)
  if (is.null(parsed$results) || nrow(parsed$results) == 0) {
    stop(paste("No Massive data returned for", ticker))
  }

  result <- as.data.frame(parsed$results)

  tibble::tibble(
    ticker = ticker,
    date = as.Date(lubridate::as_datetime(result$t / 1000, tz = "America/New_York")),
    open = as.numeric(result$o),
    high = as.numeric(result$h),
    low = as.numeric(result$l),
    close = as.numeric(result$c),
    volume = as.numeric(result$v),
    vwap = if ("vw" %in% names(result)) as.numeric(result$vw) else NA_real_,
    transactions = if ("n" %in% names(result)) as.numeric(result$n) else NA_real_,
    data_source = "Massive API"
  )
}

4 Yahoo Finance fallback

This fallback keeps the report usable when a key is absent or an individual Massive request fails. The data_source column clearly records which source supplied each ticker.

get_yahoo_stock <- function(ticker, from_date, to_date) {
  x <- quantmod::getSymbols(
    ticker,
    src = "yahoo",
    from = from_date,
    to = to_date + 1,
    auto.assign = FALSE,
    warnings = FALSE
  )

  colnames(x) <- c("open", "high", "low", "close", "volume", "adjusted")

  tibble::tibble(
    ticker = ticker,
    date = as.Date(zoo::index(x)),
    open = as.numeric(x[, "open"]),
    high = as.numeric(x[, "high"]),
    low = as.numeric(x[, "low"]),
    close = as.numeric(x[, "adjusted"]),
    volume = as.numeric(x[, "volume"]),
    vwap = NA_real_,
    transactions = NA_real_,
    data_source = "Yahoo Finance fallback"
  )
}

5 Download and combine stock prices

stock_results <- purrr::map(tickers, function(symbol) {
  tryCatch(
    {
      get_massive_stock(symbol, start_date, end_date, massive_key)
    },
    error = function(e) {
      message("Massive request failed for ", symbol, ": ", conditionMessage(e))
      message("Using Yahoo Finance fallback for ", symbol, ".")
      get_yahoo_stock(symbol, start_date, end_date)
    }
  )
})

stock_prices <- dplyr::bind_rows(stock_results) %>%
  dplyr::arrange(ticker, date) %>%
  dplyr::distinct(ticker, date, .keep_all = TRUE)

readr::write_csv(stock_prices, "output/stock_prices.csv")

stock_prices %>%
  dplyr::group_by(ticker, data_source) %>%
  dplyr::summarise(
    first_date = min(date),
    last_date = max(date),
    observations = dplyr::n(),
    .groups = "drop"
  ) %>%
  knitr::kable(caption = "Downloaded stock data")
Downloaded stock data
ticker data_source first_date last_date observations
AAPL Massive API 2024-08-09 2026-07-31 495
AMZN Massive API 2024-08-09 2026-07-31 495
JPM Massive API 2024-08-09 2026-07-31 495
MSFT Massive API 2024-08-09 2026-07-31 495
NVDA Massive API 2024-08-09 2026-07-31 495
SPY Yahoo Finance fallback 2024-08-09 2026-07-31 495

6 Data-quality checks

data_quality <- stock_prices %>%
  dplyr::group_by(ticker) %>%
  dplyr::summarise(
    observations = dplyr::n(),
    duplicate_dates = sum(duplicated(date)),
    missing_open = sum(is.na(open)),
    missing_close = sum(is.na(close)),
    missing_volume = sum(is.na(volume)),
    nonpositive_close = sum(close <= 0, na.rm = TRUE),
    first_date = min(date),
    last_date = max(date),
    .groups = "drop"
  )

readr::write_csv(data_quality, "output/data_quality_summary.csv")
knitr::kable(data_quality, caption = "Stock-data quality summary")
Stock-data quality summary
ticker observations duplicate_dates missing_open missing_close missing_volume nonpositive_close first_date last_date
AAPL 495 0 0 0 0 0 2024-08-09 2026-07-31
AMZN 495 0 0 0 0 0 2024-08-09 2026-07-31
JPM 495 0 0 0 0 0 2024-08-09 2026-07-31
MSFT 495 0 0 0 0 0 2024-08-09 2026-07-31
NVDA 495 0 0 0 0 0 2024-08-09 2026-07-31
SPY 495 0 0 0 0 0 2024-08-09 2026-07-31

8 Investment risk metrics

risk_metrics <- technical_indicators %>%
  dplyr::filter(!is.na(daily_return)) %>%
  dplyr::group_by(ticker) %>%
  dplyr::summarise(
    start_date = min(date),
    end_date = max(date),
    trading_days = dplyr::n(),
    total_return = prod(1 + daily_return, na.rm = TRUE) - 1,
    annualized_return = prod(1 + daily_return, na.rm = TRUE)^(252 / dplyr::n()) - 1,
    annualized_volatility = stats::sd(daily_return, na.rm = TRUE) * sqrt(252),
    sharpe_ratio = (annualized_return - risk_free_rate) / annualized_volatility,
    max_drawdown = min(
      cumprod(1 + daily_return) / cummax(cumprod(1 + daily_return)) - 1,
      na.rm = TRUE
    ),
    average_daily_volume = mean(volume, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  dplyr::arrange(dplyr::desc(sharpe_ratio))

readr::write_csv(risk_metrics, "output/risk_metrics.csv")

risk_metrics %>%
  dplyr::mutate(
    dplyr::across(
      c(total_return, annualized_return, annualized_volatility, max_drawdown),
      scales::percent,
      accuracy = 0.1
    ),
    sharpe_ratio = round(sharpe_ratio, 2),
    average_daily_volume = scales::comma(average_daily_volume)
  ) %>%
  knitr::kable(caption = "Return and risk metrics")
Return and risk metrics
ticker start_date end_date trading_days total_return annualized_return annualized_volatility sharpe_ratio max_drawdown average_daily_volume
JPM 2024-08-12 2026-07-31 494 70.9% 31.5% 25.2% 1.09 -24.9% 9,398,474
SPY 2024-08-12 2026-07-31 494 43.5% 20.2% 16.7% 0.97 -18.8% 66,281,054
NVDA 2024-08-12 2026-07-31 494 91.6% 39.4% 45.6% 0.78 -36.9% 211,587,888
AMZN 2024-08-12 2026-07-31 494 62.7% 28.2% 34.1% 0.71 -30.9% 44,582,546
AAPL 2024-08-12 2026-07-31 494 42.9% 20.0% 28.8% 0.55 -33.4% 51,837,965
MSFT 2024-08-12 2026-07-31 494 14.5% 7.1% 28.6% 0.11 -34.9% 26,302,059

9 Portfolio returns

The portfolio uses the weights defined in the project-settings section of this R Markdown file. Returns are calculated only for dates shared by all investments.

returns_wide <- daily_returns %>%
  tidyr::pivot_wider(names_from = ticker, values_from = daily_return) %>%
  tidyr::drop_na(dplyr::all_of(tickers)) %>%
  dplyr::arrange(date)

weights <- ticker_table$portfolio_weight
names(weights) <- ticker_table$ticker
weights <- weights[tickers]
weights <- weights / sum(weights)

portfolio_performance <- returns_wide %>%
  dplyr::mutate(
    portfolio_return = as.numeric(as.matrix(dplyr::select(., dplyr::all_of(tickers))) %*% weights),
    portfolio_value = initial_investment * cumprod(1 + portfolio_return),
    cumulative_return = portfolio_value / initial_investment - 1,
    drawdown = portfolio_value / cummax(portfolio_value) - 1
  ) %>%
  dplyr::select(date, portfolio_return, portfolio_value, cumulative_return, drawdown)

readr::write_csv(portfolio_performance, "output/portfolio_performance.csv")

portfolio_summary <- tibble::tibble(
  metric = c(
    "Beginning value", "Ending value", "Total return",
    "Annualized return", "Annualized volatility", "Sharpe ratio", "Maximum drawdown"
  ),
  value = c(
    initial_investment,
    dplyr::last(portfolio_performance$portfolio_value),
    dplyr::last(portfolio_performance$cumulative_return),
    prod(1 + portfolio_performance$portfolio_return)^(252 / nrow(portfolio_performance)) - 1,
    stats::sd(portfolio_performance$portfolio_return) * sqrt(252),
    (prod(1 + portfolio_performance$portfolio_return)^(252 / nrow(portfolio_performance)) - 1 - risk_free_rate) /
      (stats::sd(portfolio_performance$portfolio_return) * sqrt(252)),
    min(portfolio_performance$drawdown)
  )
)

knitr::kable(portfolio_summary, digits = 3, caption = "Equal-weight portfolio summary")
Equal-weight portfolio summary
metric value
Beginning value 10000.000
Ending value 15818.207
Total return 0.582
Annualized return 0.264
Annualized volatility 0.228
Sharpe ratio 0.980
Maximum drawdown -0.239

10 FRED macroeconomic data

fred_symbols <- c("FEDFUNDS", "CPIAUCSL", "UNRATE", "VIXCLS", "UMCSENT")

fred_list <- purrr::map(fred_symbols, function(symbol) {
  x <- quantmod::getSymbols(
    symbol,
    src = "FRED",
    from = start_date,
    to = end_date,
    auto.assign = FALSE
  )
  tibble::tibble(
    date = as.Date(zoo::index(x)),
    value = as.numeric(x),
    indicator = symbol
  )
})

fred_economic_data <- dplyr::bind_rows(fred_list) %>%
  tidyr::pivot_wider(names_from = indicator, values_from = value) %>%
  dplyr::arrange(date)

readr::write_csv(fred_economic_data, "output/fred_economic_data.csv")

fred_economic_data <- fred_economic_data %>%
  dplyr::filter(
    date >= max(date, na.rm = TRUE) - 183
  ) %>%
  dplyr::slice_tail(n = 12) %>%
  knitr::kable(
    caption = "Recent FRED observations"
  )

11 Alpha Vantage company fundamentals

This section uses the OVERVIEW endpoint. It pauses between requests to reduce the chance of exceeding a free-plan rate limit. When the key is absent, an empty CSV is created so the report still knits.

get_alpha_overview <- function(ticker, api_key) {
  if (!nzchar(api_key)) stop("ALPHAVANTAGE_API_KEY is not available.")

  response <- httr::GET(
    "https://www.alphavantage.co/query",
    query = list(`function` = "OVERVIEW", symbol = ticker, apikey = api_key),
    httr::timeout(30)
  )
  httr::stop_for_status(response)

  parsed <- jsonlite::fromJSON(
    httr::content(response, "text", encoding = "UTF-8"),
    simplifyVector = TRUE
  )

  if (length(parsed) == 0 || !is.null(parsed$Information) || !is.null(parsed$Note)) {
    stop(paste("No Alpha Vantage overview returned for", ticker))
  }

  value_or_na <- function(name) {
    if (!is.null(parsed[[name]]) && parsed[[name]] != "None") parsed[[name]] else NA_character_
  }

  tibble::tibble(
    ticker = ticker,
    company_name = value_or_na("Name"),
    description = value_or_na("Description"),
    exchange = value_or_na("Exchange"),
    currency = value_or_na("Currency"),
    country = value_or_na("Country"),
    sector = value_or_na("Sector"),
    industry = value_or_na("Industry"),
    market_capitalization = suppressWarnings(as.numeric(value_or_na("MarketCapitalization"))),
    pe_ratio = suppressWarnings(as.numeric(value_or_na("PERatio"))),
    peg_ratio = suppressWarnings(as.numeric(value_or_na("PEGRatio"))),
    book_value = suppressWarnings(as.numeric(value_or_na("BookValue"))),
    dividend_per_share = suppressWarnings(as.numeric(value_or_na("DividendPerShare"))),
    dividend_yield = suppressWarnings(as.numeric(value_or_na("DividendYield"))),
    eps = suppressWarnings(as.numeric(value_or_na("EPS"))),
    profit_margin = suppressWarnings(as.numeric(value_or_na("ProfitMargin"))),
    beta = suppressWarnings(as.numeric(value_or_na("Beta"))),
    fifty_two_week_high = suppressWarnings(as.numeric(value_or_na("52WeekHigh"))),
    fifty_two_week_low = suppressWarnings(as.numeric(value_or_na("52WeekLow"))),
    analyst_target_price = suppressWarnings(as.numeric(value_or_na("AnalystTargetPrice")))
  )
}
if (nzchar(alpha_key)) {
  fundamental_tickers <- ticker_table %>%
    dplyr::filter(asset_type == "Stock") %>%
    dplyr::pull(ticker)

  fundamental_results <- vector("list", length(fundamental_tickers))

  for (i in seq_along(fundamental_tickers)) {
    symbol <- fundamental_tickers[i]
    fundamental_results[[i]] <- tryCatch(
      get_alpha_overview(symbol, alpha_key),
      error = function(e) {
        message("Alpha Vantage request failed for ", symbol, ": ", conditionMessage(e))
        tibble::tibble(ticker = symbol)
      }
    )
    if (i < length(fundamental_tickers)) Sys.sleep(12)
  }

  company_fundamentals <- dplyr::bind_rows(fundamental_results)
} else {
  company_fundamentals <- tibble::tibble(
    ticker = character(), company_name = character(), sector = character(),
    industry = character(), market_capitalization = double(), pe_ratio = double(),
    dividend_yield = double(), beta = double()
  )
}

readr::write_csv(company_fundamentals, "output/company_fundamentals.csv")

if (nrow(company_fundamentals) > 0) {
  company_fundamentals %>%
    dplyr::select(dplyr::any_of(c(
      "ticker", "company_name", "sector", "industry",
      "market_capitalization", "pe_ratio", "dividend_yield", "beta"
    ))) %>%
    knitr::kable(caption = "Alpha Vantage company fundamentals")
} else {
  cat("Alpha Vantage fundamentals were skipped because no API key was available.")
}
Alpha Vantage company fundamentals
ticker
AAPL
MSFT
NVDA
AMZN
JPM

12 Finance-headline web scraping

The scraper tries multiple finance pages and several common headline selectors. This is true HTML scraping with rvest. Because websites change, the code fails gracefully and records the source that worked.

absolute_url <- function(link, base_url) {
  ifelse(
    stringr::str_detect(link, "^https?://"),
    link,
    xml2::url_absolute(link, base_url)
  )
}

scrape_headlines <- function(page_url, source_name) {
  page <- rvest::read_html(
    httr::GET(
      page_url,
      httr::user_agent("Mozilla/5.0 investment analytics class project"),
      httr::timeout(30)
    )
  )

  selectors <- c(
    "h3 a", "h2 a", "a[data-testid*='headline']",
    "a[href*='/news/']", "a[href*='/markets/']"
  )

  nodes <- purrr::map(selectors, ~ rvest::html_elements(page, .x)) %>%
    purrr::list_flatten()

  if (length(nodes) == 0) stop("No headline elements found.")

  tibble::tibble(
    headline = rvest::html_text2(nodes),
    url = rvest::html_attr(nodes, "href")
  ) %>%
    dplyr::mutate(
      headline = stringr::str_squish(headline),
      url = absolute_url(url, page_url),
      source = source_name,
      scraped_at = Sys.time()
    ) %>%
    dplyr::filter(
      !is.na(url),
      stringr::str_length(headline) >= 25,
      stringr::str_detect(url, "^https?://")
    ) %>%
    dplyr::distinct(headline, .keep_all = TRUE) %>%
    dplyr::slice_head(n = 40)
}
headline_sources <- tibble::tribble(
  ~page_url, ~source_name,
  "https://finance.yahoo.com/topic/stock-market-news/", "Yahoo Finance",
  "https://www.cnbc.com/markets/", "CNBC Markets",
  "https://www.marketwatch.com/markets", "MarketWatch"
)

headline_attempts <- purrr::pmap(
  headline_sources,
  function(page_url, source_name) {
    tryCatch(
      scrape_headlines(page_url, source_name),
      error = function(e) {
        message("Headline scraping failed for ", source_name, ": ", conditionMessage(e))
        NULL
      }
    )
  }
)

finance_headlines <- dplyr::bind_rows(headline_attempts)

if (
  nrow(finance_headlines) == 0 ||
  !"headline" %in% names(finance_headlines)
) {
  finance_headlines <- tibble::tibble(
    headline = character(),
    url = character(),
    source = character(),
    scraped_at = as.POSIXct(character())
  )
}

finance_headlines <- finance_headlines %>%
  dplyr::distinct(headline, .keep_all = TRUE)

if (nrow(finance_headlines) == 0) {
  finance_headlines <- tibble::tibble(
    headline = paste(
      "No headlines were returned because all websites",
      "blocked or changed their HTML structure."
    ),
    url = NA_character_,
    source = "No successful source",
    scraped_at = Sys.time()
  )
}

readr::write_csv(finance_headlines, "output/finance_headlines.csv")

finance_headlines %>%
  dplyr::select(source, headline, url) %>%
  dplyr::slice_head(n = 20) %>%
  knitr::kable(caption = "Scraped finance headlines")
Scraped finance headlines
source headline url
No successful source No headlines were returned because all websites blocked or changed their HTML structure. NA

13 Headline word analysis

headline_word_counts <- finance_headlines %>%
  dplyr::filter(source != "No successful source") %>%
  tidytext::unnest_tokens(word, headline) %>%
  dplyr::anti_join(tidytext::stop_words, by = "word") %>%
  dplyr::filter(stringr::str_detect(word, "[a-z]")) %>%
  dplyr::count(word, sort = TRUE)

readr::write_csv(headline_word_counts, "output/headline_word_counts.csv")

if (nrow(headline_word_counts) > 0) {
  headline_word_counts %>%
    dplyr::slice_head(n = 15) %>%
    knitr::kable(caption = "Most common meaningful headline words")
}

14 Investment conjoint survey

The group survey is a conjoint-style investment preference study. Each of the 20 respondents evaluates five hypothetical investment profiles and ranks them from 1 (most preferred) to 5 (least preferred). The profiles vary across five attributes that connect directly to the external market datasets used in this project:

  • Investment type: Individual Stock or ETF
  • Expected annual return: 8% or 15%
  • Risk level: Moderate or High
  • Primary information source: Company Financials or Market News & Economic Indicators
  • Dividend: Pays Dividend or No Dividend

Before knitting, upload the completed workbook to the Posit Cloud project folder and name it exactly:

Investment_Conjoint_Survey Group1.xlsx

Enter completed rankings in the Investment Survey sheet. The code below reads that sheet directly, so respondents do not need to copy their answers into another worksheet.

14.1 Import and clean survey responses

survey_file <- "Investment_Conjoint_Survey Group1.xlsx"
survey_sheet <- "Investment Survey"

clean_variable_names <- function(x) {
  x %>%
    stringr::str_trim() %>%
    stringr::str_to_lower() %>%
    stringr::str_replace_all("[^a-z0-9]+", "_") %>%
    stringr::str_replace_all("^_+|_+$", "") %>%
    make.unique(sep = "_")
}

if (file.exists(survey_file)) {
  survey_raw <- readxl::read_excel(
    path = survey_file,
    sheet = survey_sheet,
    skip = 4,
    na = c("", "NA", "N/A")
  )

  names(survey_raw) <- clean_variable_names(names(survey_raw))

  survey_clean <- survey_raw %>%
    dplyr::transmute(
      respondent_id = suppressWarnings(as.integer(respondent_id)),
      profile = stringr::str_squish(as.character(profile)),
      investment_type = stringr::str_squish(as.character(investment_type)),
      expected_return = stringr::str_squish(as.character(expected_annual_return)),
      risk_level = stringr::str_squish(as.character(risk_level)),
      information_source = stringr::str_squish(as.character(primary_information_source)),
      dividend = stringr::str_squish(as.character(dividend)),
      rank = suppressWarnings(as.integer(rank))
    ) %>%
    dplyr::filter(!is.na(respondent_id), !is.na(profile)) %>%
    dplyr::distinct(respondent_id, profile, .keep_all = TRUE) %>%
    dplyr::mutate(
      expected_return_numeric = readr::parse_number(expected_return) / 100,
      preference_score = dplyr::if_else(rank %in% 1:5, 6 - rank, NA_integer_),
      completed = rank %in% 1:5
    ) %>%
    dplyr::arrange(respondent_id, profile)

  respondent_quality <- survey_clean %>%
    dplyr::group_by(respondent_id) %>%
    dplyr::summarise(
      profiles_assigned = dplyr::n(),
      profiles_ranked = sum(completed),
      unique_ranks_used = dplyr::n_distinct(rank[completed]),
      missing_ranks = sum(!completed),
      valid_ranking = profiles_assigned == 5 & profiles_ranked == 5 &
        unique_ranks_used == 5 & setequal(rank[completed], 1:5),
      .groups = "drop"
    )

  survey_quality_summary <- tibble::tibble(
    measure = c(
      "Profiles in survey design",
      "Expected respondents",
      "Respondents represented",
      "Completed ranking rows",
      "Respondents with five valid unique ranks",
      "Respondents requiring correction",
      "Missing or invalid rank entries"
    ),
    value = c(
      nrow(survey_clean),
      25,
      dplyr::n_distinct(survey_clean$respondent_id),
      sum(survey_clean$completed),
      sum(respondent_quality$valid_ranking),
      sum(!respondent_quality$valid_ranking),
      sum(!survey_clean$completed)
    )
  )

  survey_variable_documentation <- tibble::tribble(
    ~variable, ~data_type, ~nonmissing_values, ~missing_values, ~unique_values, ~example_value, ~description,
    "respondent_id", "integer", sum(!is.na(survey_clean$respondent_id)), sum(is.na(survey_clean$respondent_id)), dplyr::n_distinct(survey_clean$respondent_id, na.rm = TRUE), "1", "Anonymous respondent identifier from 1 through 25",
    "profile", "character", sum(!is.na(survey_clean$profile)), sum(is.na(survey_clean$profile)), dplyr::n_distinct(survey_clean$profile, na.rm = TRUE), "A", "Profile label shown to the respondent",
    "investment_type", "character", sum(!is.na(survey_clean$investment_type)), sum(is.na(survey_clean$investment_type)), dplyr::n_distinct(survey_clean$investment_type, na.rm = TRUE), "ETF", "Investment vehicle presented in the profile",
    "expected_return", "character", sum(!is.na(survey_clean$expected_return)), sum(is.na(survey_clean$expected_return)), dplyr::n_distinct(survey_clean$expected_return, na.rm = TRUE), "8%", "Expected annual return displayed in the profile",
    "risk_level", "character", sum(!is.na(survey_clean$risk_level)), sum(is.na(survey_clean$risk_level)), dplyr::n_distinct(survey_clean$risk_level, na.rm = TRUE), "Moderate", "Investment risk level displayed in the profile",
    "information_source", "character", sum(!is.na(survey_clean$information_source)), sum(is.na(survey_clean$information_source)), dplyr::n_distinct(survey_clean$information_source, na.rm = TRUE), "Company Financials", "Primary information source used to evaluate the investment",
    "dividend", "character", sum(!is.na(survey_clean$dividend)), sum(is.na(survey_clean$dividend)), dplyr::n_distinct(survey_clean$dividend, na.rm = TRUE), "Pays Dividend", "Whether the investment pays a dividend",
    "rank", "integer", sum(!is.na(survey_clean$rank)), sum(is.na(survey_clean$rank)), dplyr::n_distinct(survey_clean$rank, na.rm = TRUE), "1", "Preference rank where 1 is most preferred and 5 is least preferred",
    "preference_score", "integer", sum(!is.na(survey_clean$preference_score)), sum(is.na(survey_clean$preference_score)), dplyr::n_distinct(survey_clean$preference_score, na.rm = TRUE), "5", "Reverse-coded preference score where larger values indicate stronger preference"
  )

  readr::write_csv(survey_clean, "output/investment_conjoint_responses_clean.csv")
  readr::write_csv(respondent_quality, "output/investment_conjoint_response_quality.csv")
  readr::write_csv(survey_quality_summary, "output/investment_conjoint_quality_summary.csv")
  readr::write_csv(survey_variable_documentation, "output/investment_conjoint_variable_documentation.csv")

  knitr::kable(survey_quality_summary, caption = "Investment survey completion and quality summary")
} else {
  survey_raw <- tibble::tibble()
  survey_clean <- tibble::tibble()
  respondent_quality <- tibble::tibble()
  survey_quality_summary <- tibble::tibble(
    measure = "Survey workbook status",
    value = paste(survey_file, "was not found")
  )
  survey_variable_documentation <- tibble::tibble(
    variable = character(), data_type = character(), nonmissing_values = integer(),
    missing_values = integer(), unique_values = integer(), example_value = character(),
    description = character()
  )
  cat("**Survey workbook not found:** Upload `Investment_Conjoint_Survey Group1.xlsx` to the Posit Cloud project folder and knit again. The investment-data sections will still run.")
}
Investment survey completion and quality summary
measure value
Profiles in survey design 100
Expected respondents 25
Respondents represented 20
Completed ranking rows 55
Respondents with five valid unique ranks 9
Respondents requiring correction 11
Missing or invalid rank entries 45

14.2 Survey response preview

if (nrow(survey_clean) > 0) {
  survey_clean %>%
    dplyr::slice_head(n = 15) %>%
    knitr::kable(caption = "Preview of cleaned investment conjoint responses")
}
Preview of cleaned investment conjoint responses
respondent_id profile investment_type expected_return risk_level information_source dividend rank expected_return_numeric preference_score completed
1 A ETF 8% High Market News & Economic Indicators No Dividend NA 0.08 NA FALSE
1 B ETF 15% High Company Financials Pays Dividend NA 0.15 NA FALSE
1 C ETF 15% Moderate Company Financials No Dividend NA 0.15 NA FALSE
1 D Individual Stock 8% Moderate Market News & Economic Indicators Pays Dividend NA 0.08 NA FALSE
1 E ETF 15% Moderate Company Financials Pays Dividend NA 0.15 NA FALSE
2 A ETF 15% Moderate Market News & Economic Indicators Pays Dividend NA 0.15 NA FALSE
2 B Individual Stock 15% Moderate Company Financials No Dividend NA 0.15 NA FALSE
2 C ETF 8% Moderate Company Financials Pays Dividend NA 0.08 NA FALSE
2 D ETF 8% Moderate Market News & Economic Indicators No Dividend NA 0.08 NA FALSE
2 E Individual Stock 15% Moderate Market News & Economic Indicators No Dividend NA 0.15 NA FALSE
3 A Individual Stock 15% Moderate Company Financials Pays Dividend NA 0.15 NA FALSE
3 B ETF 8% High Company Financials Pays Dividend NA 0.08 NA FALSE
3 C Individual Stock 8% Moderate Market News & Economic Indicators No Dividend NA 0.08 NA FALSE
3 D Individual Stock 8% High Market News & Economic Indicators Pays Dividend NA 0.08 NA FALSE
3 E ETF 8% Moderate Company Financials No Dividend NA 0.08 NA FALSE

14.3 Respondent ranking validation

Every respondent should assign each rank from 1 through 5 exactly once. This table identifies incomplete responses or duplicate ranks before the data are used in the conjoint model.

if (nrow(respondent_quality) > 0) {
  respondent_quality %>%
    dplyr::mutate(valid_ranking = ifelse(valid_ranking, "Valid", "Review")) %>%
    knitr::kable(caption = "Ranking validation by respondent")
}
Ranking validation by respondent
respondent_id profiles_assigned profiles_ranked unique_ranks_used missing_ranks valid_ranking
1 5 0 0 5 Review
2 5 0 0 5 Review
3 5 0 0 5 Review
4 5 0 0 5 Review
5 5 5 5 0 Valid
6 5 0 0 5 Review
7 5 5 5 0 Valid
8 5 0 0 5 Review
9 5 5 5 0 Valid
10 5 5 5 0 Valid
11 5 5 5 0 Valid
12 5 0 0 5 Review
13 5 5 5 0 Valid
14 5 5 5 0 Valid
15 5 0 0 5 Review
16 5 5 5 0 Valid
17 5 5 5 0 Valid
18 5 5 3 0 Review
19 5 0 0 5 Review
20 5 5 3 0 Review

14.4 Average preference by attribute level

Lower average rank means stronger preference. The table also reports the reverse-coded preference score, where a larger value means the profile was preferred more strongly.

if (nrow(survey_clean) > 0) {
  valid_survey <- survey_clean %>%
    dplyr::filter(completed) %>%
    dplyr::semi_join(
      respondent_quality %>% dplyr::filter(valid_ranking),
      by = "respondent_id"
    )
} else {
  valid_survey <- tibble::tibble()
}

if (nrow(valid_survey) > 0) {
  conjoint_level_summary <- dplyr::bind_rows(
    valid_survey %>% dplyr::group_by(level = investment_type) %>% dplyr::summarise(attribute = "Investment Type", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop"),
    valid_survey %>% dplyr::group_by(level = expected_return) %>% dplyr::summarise(attribute = "Expected Annual Return", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop"),
    valid_survey %>% dplyr::group_by(level = risk_level) %>% dplyr::summarise(attribute = "Risk Level", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop"),
    valid_survey %>% dplyr::group_by(level = information_source) %>% dplyr::summarise(attribute = "Information Source", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop"),
    valid_survey %>% dplyr::group_by(level = dividend) %>% dplyr::summarise(attribute = "Dividend", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop")
  ) %>%
    dplyr::arrange(attribute, dplyr::desc(average_preference_score))

  readr::write_csv(conjoint_level_summary, "output/investment_conjoint_level_summary.csv")

  conjoint_level_summary %>%
    dplyr::mutate(
      average_rank = round(average_rank, 2),
      average_preference_score = round(average_preference_score, 2)
    ) %>%
    knitr::kable(caption = "Average investment preference by attribute level")
}
Average investment preference by attribute level
level attribute responses average_rank average_preference_score
No Dividend Dividend 23 2.87 3.13
Pays Dividend Dividend 22 3.14 2.86
15% Expected Annual Return 24 2.83 3.17
8% Expected Annual Return 21 3.19 2.81
Company Financials Information Source 22 3.00 3.00
Market News & Economic Indicators Information Source 23 3.00 3.00
ETF Investment Type 23 3.00 3.00
Individual Stock Investment Type 22 3.00 3.00
Moderate Risk Level 20 2.55 3.45
High Risk Level 25 3.36 2.64
if (exists("conjoint_level_summary") && nrow(conjoint_level_summary) > 0) {
  ggplot2::ggplot(
    conjoint_level_summary,
    ggplot2::aes(x = reorder(level, average_preference_score), y = average_preference_score)
  ) +
    ggplot2::geom_col() +
    ggplot2::coord_flip() +
    ggplot2::facet_wrap(~ attribute, scales = "free_y", ncol = 2) +
    ggplot2::labs(
      title = "Average Preference Score by Investment Attribute Level",
      subtitle = "Higher scores indicate stronger preference",
      x = NULL,
      y = "Average preference score"
    ) +
    ggplot2::theme_minimal()
}

14.5 Conjoint regression model

The regression uses the reverse-coded preference score as the outcome. Respondent fixed effects control for differences in how individual students use the ranking scale. Positive coefficients indicate that an attribute level increases predicted preference relative to its reference level.

if (nrow(valid_survey) >= 20 && dplyr::n_distinct(valid_survey$respondent_id) >= 4) {
  conjoint_model <- stats::lm(
    preference_score ~ factor(respondent_id) + investment_type + expected_return +
      risk_level + information_source + dividend,
    data = valid_survey
  )

  coefficient_table <- as.data.frame(summary(conjoint_model)$coefficients) %>%
    tibble::rownames_to_column("term") %>%
    dplyr::rename(
      estimate = Estimate,
      standard_error = `Std. Error`,
      t_value = `t value`,
      p_value = `Pr(>|t|)`
    ) %>%
    dplyr::filter(
      !stringr::str_detect(term, "^factor\\(respondent_id\\)"),
      term != "(Intercept)"
    ) %>%
    dplyr::mutate(
      attribute = dplyr::case_when(
        stringr::str_starts(term, "investment_type") ~ "Investment Type",
        stringr::str_starts(term, "expected_return") ~ "Expected Annual Return",
        stringr::str_starts(term, "risk_level") ~ "Risk Level",
        stringr::str_starts(term, "information_source") ~ "Information Source",
        stringr::str_starts(term, "dividend") ~ "Dividend",
        TRUE ~ "Other"
      ),
      level = term %>%
        stringr::str_remove("^investment_type") %>%
        stringr::str_remove("^expected_return") %>%
        stringr::str_remove("^risk_level") %>%
        stringr::str_remove("^information_source") %>%
        stringr::str_remove("^dividend")
    )

  readr::write_csv(coefficient_table, "output/investment_conjoint_coefficients.csv")

  coefficient_table %>%
    dplyr::mutate(
      estimate = round(estimate, 3),
      standard_error = round(standard_error, 3),
      p_value = round(p_value, 4)
    ) %>%
    dplyr::select(attribute, level, estimate, standard_error, p_value) %>%
    knitr::kable(caption = "Estimated conjoint preference effects")
} else {
  conjoint_model <- NULL
  coefficient_table <- tibble::tibble()
  cat("A conjoint model will be estimated after at least four respondents provide complete, valid rankings.")
}
Estimated conjoint preference effects
attribute level estimate standard_error p_value
Investment Type Individual Stock -0.023 0.539 0.9669
Expected Annual Return 8% -0.303 0.512 0.5577
Risk Level Moderate 0.961 0.530 0.0794
Information Source Market News & Economic Indicators 0.129 0.499 0.7981
Dividend Pays Dividend -0.354 0.563 0.5338
if (nrow(coefficient_table) > 0) {
  ggplot2::ggplot(
    coefficient_table,
    ggplot2::aes(x = reorder(paste(attribute, level, sep = ": "), estimate), y = estimate)
  ) +
    ggplot2::geom_col() +
    ggplot2::geom_hline(yintercept = 0, linetype = "dashed") +
    ggplot2::coord_flip() +
    ggplot2::labs(
      title = "Estimated Effects on Investment Preference",
      subtitle = "Positive values increase predicted preference relative to the reference level",
      x = NULL,
      y = "Estimated preference effect"
    ) +
    ggplot2::theme_minimal()
}

14.6 Predicted profile preferences

This section scores every unique investment profile in the survey design using the fitted conjoint model. The highest predicted preference identifies the combination of features that best matches the class sample.

if (!is.null(conjoint_model)) {
  profile_design <- survey_clean %>%
    dplyr::distinct(
      investment_type,
      expected_return,
      risk_level,
      information_source,
      dividend
    )

  reference_respondent <- valid_survey$respondent_id[1]

  prediction_data <- profile_design %>%
    dplyr::mutate(
      respondent_id = reference_respondent
    )

  predicted_values <- stats::predict(
    conjoint_model,
    newdata = prediction_data
  )

  profile_predictions <- prediction_data %>%
    dplyr::mutate(
      predicted_preference = as.numeric(predicted_values)
    ) %>%
    dplyr::arrange(dplyr::desc(predicted_preference)) %>%
    dplyr::mutate(
      predicted_rank = dplyr::row_number()
    ) %>%
    dplyr::select(
      predicted_rank,
      investment_type,
      expected_return,
      risk_level,
      information_source,
      dividend,
      predicted_preference
    )

  readr::write_csv(
    profile_predictions,
    "output/investment_conjoint_profile_predictions.csv"
  )

  profile_predictions %>%
    dplyr::slice_head(n = 10) %>%
    dplyr::mutate(
      predicted_preference = round(predicted_preference, 3)
    ) %>%
    knitr::kable(
      caption = "Top predicted investment profiles"
    )
}
Top predicted investment profiles
predicted_rank investment_type expected_return risk_level information_source dividend predicted_preference
1 ETF 15% Moderate Market News & Economic Indicators No Dividend 4.103
2 Individual Stock 15% Moderate Market News & Economic Indicators No Dividend 4.081
3 ETF 15% Moderate Company Financials No Dividend 3.974
4 Individual Stock 15% Moderate Company Financials No Dividend 3.952
5 ETF 8% Moderate Market News & Economic Indicators No Dividend 3.800
6 Individual Stock 8% Moderate Market News & Economic Indicators No Dividend 3.777
7 ETF 15% Moderate Market News & Economic Indicators Pays Dividend 3.749
8 Individual Stock 15% Moderate Market News & Economic Indicators Pays Dividend 3.727
9 ETF 8% Moderate Company Financials No Dividend 3.671
10 Individual Stock 8% Moderate Company Financials No Dividend 3.649

14.7 Survey interpretation

if (exists("conjoint_level_summary") && nrow(conjoint_level_summary) > 0) {
  preferred_levels <- conjoint_level_summary %>%
    dplyr::group_by(attribute) %>%
    dplyr::slice_max(average_preference_score, n = 1, with_ties = FALSE) %>%
    dplyr::ungroup()

  cat("Among the completed and valid class responses, the most preferred level within each attribute was: ")
  cat(paste0(preferred_levels$attribute, " = ", preferred_levels$level, collapse = "; "))
  cat(". These findings describe the participating class sample and should not be generalized to all investors.")
} else {
  cat("Survey interpretations will appear after completed rankings are entered in the workbook.")
}

Among the completed and valid class responses, the most preferred level within each attribute was: Dividend = No Dividend; Expected Annual Return = 15%; Information Source = Company Financials; Investment Type = ETF; Risk Level = Moderate. These findings describe the participating class sample and should not be generalized to all investors.

15 Documentation of variables

The table below documents the principal variables created for the stock, portfolio, economic, fundamental, headline, and survey datasets. This file is exported for the group as output/data_dictionary.csv.

core_data_dictionary <- tibble::tribble(
  ~dataset, ~variable, ~data_type, ~description,
  "stock_prices", "ticker", "character", "Trading symbol for the stock or ETF",
  "stock_prices", "date", "Date", "Trading date",
  "stock_prices", "open", "numeric", "Opening market price",
  "stock_prices", "high", "numeric", "Highest market price during the day",
  "stock_prices", "low", "numeric", "Lowest market price during the day",
  "stock_prices", "close", "numeric", "Adjusted closing price used in the analysis",
  "stock_prices", "volume", "numeric", "Number of shares traded",
  "stock_prices", "vwap", "numeric", "Volume-weighted average price when supplied by Massive",
  "stock_prices", "transactions", "numeric", "Reported transaction count when supplied by Massive",
  "stock_prices", "data_source", "character", "Source used for the ticker: Massive or Yahoo fallback",
  "technical_indicators", "daily_return", "numeric", "One-day percentage price return in decimal form",
  "technical_indicators", "sma_20", "numeric", "Twenty-trading-day simple moving average",
  "technical_indicators", "sma_50", "numeric", "Fifty-trading-day simple moving average",
  "technical_indicators", "average_volume_20", "numeric", "Twenty-day average trading volume",
  "technical_indicators", "rolling_volatility_20", "numeric", "Annualized volatility calculated over twenty trading days",
  "risk_metrics", "total_return", "numeric", "Cumulative return over the analysis period",
  "risk_metrics", "annualized_return", "numeric", "Return converted to an annual rate",
  "risk_metrics", "annualized_volatility", "numeric", "Annualized standard deviation of daily returns",
  "risk_metrics", "sharpe_ratio", "numeric", "Annualized excess return divided by annualized volatility",
  "risk_metrics", "max_drawdown", "numeric", "Largest percentage decline from a prior peak",
  "portfolio_performance", "portfolio_return", "numeric", "Weighted daily portfolio return",
  "portfolio_performance", "portfolio_value", "numeric", "Value of the hypothetical investment",
  "portfolio_performance", "cumulative_return", "numeric", "Portfolio return since the beginning of the period",
  "portfolio_performance", "drawdown", "numeric", "Portfolio decline from its prior peak",
  "fred_economic_data", "FEDFUNDS", "numeric", "Effective federal funds rate",
  "fred_economic_data", "CPIAUCSL", "numeric", "Consumer Price Index for All Urban Consumers",
  "fred_economic_data", "UNRATE", "numeric", "United States unemployment rate",
  "fred_economic_data", "VIXCLS", "numeric", "CBOE volatility index closing value",
  "fred_economic_data", "UMCSENT", "numeric", "University of Michigan consumer sentiment index",
  "company_fundamentals", "market_capitalization", "numeric", "Company market capitalization from Alpha Vantage",
  "company_fundamentals", "pe_ratio", "numeric", "Price-to-earnings ratio",
  "company_fundamentals", "dividend_yield", "numeric", "Dividend yield in decimal form",
  "company_fundamentals", "beta", "numeric", "Estimated market beta",
  "finance_headlines", "headline", "character", "Finance headline collected with rvest",
  "finance_headlines", "url", "character", "Web address associated with the headline",
  "finance_headlines", "source", "character", "Website from which the headline was collected",
  "finance_headlines", "scraped_at", "POSIXct", "Date and time the headline was collected"
)

survey_dictionary_for_export <- survey_variable_documentation %>%
  dplyr::transmute(
    dataset = "investment_conjoint_responses_clean",
    variable,
    data_type,
    description
  )

data_dictionary <- dplyr::bind_rows(
  core_data_dictionary,
  survey_dictionary_for_export
)

readr::write_csv(data_dictionary, "output/data_dictionary.csv")

knitr::kable(
  data_dictionary,
  caption = "Documentation of variables",
  row.names = FALSE
)
Documentation of variables
dataset variable data_type description
stock_prices ticker character Trading symbol for the stock or ETF
stock_prices date Date Trading date
stock_prices open numeric Opening market price
stock_prices high numeric Highest market price during the day
stock_prices low numeric Lowest market price during the day
stock_prices close numeric Adjusted closing price used in the analysis
stock_prices volume numeric Number of shares traded
stock_prices vwap numeric Volume-weighted average price when supplied by Massive
stock_prices transactions numeric Reported transaction count when supplied by Massive
stock_prices data_source character Source used for the ticker: Massive or Yahoo fallback
technical_indicators daily_return numeric One-day percentage price return in decimal form
technical_indicators sma_20 numeric Twenty-trading-day simple moving average
technical_indicators sma_50 numeric Fifty-trading-day simple moving average
technical_indicators average_volume_20 numeric Twenty-day average trading volume
technical_indicators rolling_volatility_20 numeric Annualized volatility calculated over twenty trading days
risk_metrics total_return numeric Cumulative return over the analysis period
risk_metrics annualized_return numeric Return converted to an annual rate
risk_metrics annualized_volatility numeric Annualized standard deviation of daily returns
risk_metrics sharpe_ratio numeric Annualized excess return divided by annualized volatility
risk_metrics max_drawdown numeric Largest percentage decline from a prior peak
portfolio_performance portfolio_return numeric Weighted daily portfolio return
portfolio_performance portfolio_value numeric Value of the hypothetical investment
portfolio_performance cumulative_return numeric Portfolio return since the beginning of the period
portfolio_performance drawdown numeric Portfolio decline from its prior peak
fred_economic_data FEDFUNDS numeric Effective federal funds rate
fred_economic_data CPIAUCSL numeric Consumer Price Index for All Urban Consumers
fred_economic_data UNRATE numeric United States unemployment rate
fred_economic_data VIXCLS numeric CBOE volatility index closing value
fred_economic_data UMCSENT numeric University of Michigan consumer sentiment index
company_fundamentals market_capitalization numeric Company market capitalization from Alpha Vantage
company_fundamentals pe_ratio numeric Price-to-earnings ratio
company_fundamentals dividend_yield numeric Dividend yield in decimal form
company_fundamentals beta numeric Estimated market beta
finance_headlines headline character Finance headline collected with rvest
finance_headlines url character Web address associated with the headline
finance_headlines source character Website from which the headline was collected
finance_headlines scraped_at POSIXct Date and time the headline was collected
investment_conjoint_responses_clean respondent_id integer Anonymous respondent identifier from 1 through 25
investment_conjoint_responses_clean profile character Profile label shown to the respondent
investment_conjoint_responses_clean investment_type character Investment vehicle presented in the profile
investment_conjoint_responses_clean expected_return character Expected annual return displayed in the profile
investment_conjoint_responses_clean risk_level character Investment risk level displayed in the profile
investment_conjoint_responses_clean information_source character Primary information source used to evaluate the investment
investment_conjoint_responses_clean dividend character Whether the investment pays a dividend
investment_conjoint_responses_clean rank integer Preference rank where 1 is most preferred and 5 is least preferred
investment_conjoint_responses_clean preference_score integer Reverse-coded preference score where larger values indicate stronger preference

16 Visualizations

16.1 Closing prices and moving averages

chart_ticker <- "AAPL"

price_chart <- technical_indicators %>%
  dplyr::filter(ticker == chart_ticker) %>%
  ggplot2::ggplot(ggplot2::aes(x = date)) +
  ggplot2::geom_line(ggplot2::aes(y = close), linewidth = 0.6) +
  ggplot2::geom_line(ggplot2::aes(y = sma_20), linewidth = 0.7, linetype = "dashed") +
  ggplot2::geom_line(ggplot2::aes(y = sma_50), linewidth = 0.7, linetype = "dotted") +
  ggplot2::labs(
    title = paste(chart_ticker, "closing price with moving averages"),
    subtitle = "Solid: close | Dashed: 20-day SMA | Dotted: 50-day SMA",
    x = NULL,
    y = "Price"
  ) +
  ggplot2::theme_minimal()

price_chart

ggplot2::ggsave("figures/price_moving_averages.png", price_chart, width = 10, height = 6)

16.2 Volume trend

volume_chart <- technical_indicators %>%
  dplyr::filter(ticker == chart_ticker) %>%
  ggplot2::ggplot(ggplot2::aes(x = date)) +
  ggplot2::geom_col(ggplot2::aes(y = volume), width = 1) +
  ggplot2::geom_line(ggplot2::aes(y = average_volume_20), linewidth = 0.7) +
  ggplot2::scale_y_continuous(labels = scales::label_number(scale_cut = scales::cut_short_scale())) +
  ggplot2::labs(
    title = paste(chart_ticker, "daily volume and 20-day average"),
    x = NULL,
    y = "Volume"
  ) +
  ggplot2::theme_minimal()

volume_chart

ggplot2::ggsave("figures/volume_trend.png", volume_chart, width = 10, height = 6)

16.3 Risk versus return

risk_return_chart <- risk_metrics %>%
  ggplot2::ggplot(
    ggplot2::aes(
      x = annualized_volatility,
      y = annualized_return,
      label = ticker,
      size = average_daily_volume
    )
  ) +
  ggplot2::geom_point(alpha = 0.7) +
  ggplot2::geom_text(nudge_y = 0.015, show.legend = FALSE) +
  ggplot2::scale_x_continuous(labels = scales::label_percent()) +
  ggplot2::scale_y_continuous(labels = scales::label_percent()) +
  ggplot2::scale_size_continuous(labels = scales::label_number(scale_cut = scales::cut_short_scale())) +
  ggplot2::labs(
    title = "Annualized risk versus return",
    x = "Annualized volatility",
    y = "Annualized return",
    size = "Average volume"
  ) +
  ggplot2::theme_minimal()

risk_return_chart

ggplot2::ggsave("figures/risk_return.png", risk_return_chart, width = 10, height = 6)

16.4 Portfolio growth

portfolio_chart <- portfolio_performance %>%
  ggplot2::ggplot(ggplot2::aes(x = date, y = portfolio_value)) +
  ggplot2::geom_line(linewidth = 0.8) +
  ggplot2::scale_y_continuous(labels = scales::label_dollar()) +
  ggplot2::labs(
    title = "Growth of the hypothetical portfolio",
    x = NULL,
    y = "Portfolio value"
  ) +
  ggplot2::theme_minimal()

portfolio_chart

ggplot2::ggsave("figures/portfolio_growth.png", portfolio_chart, width = 10, height = 6)

16.5 Rolling volatility

volatility_chart <- technical_indicators %>%
  dplyr::filter(!is.na(rolling_volatility_20)) %>%
  ggplot2::ggplot(ggplot2::aes(x = date, y = rolling_volatility_20, group = ticker)) +
  ggplot2::geom_line(alpha = 0.8) +
  ggplot2::facet_wrap(~ticker, scales = "free_y") +
  ggplot2::scale_y_continuous(labels = scales::label_percent()) +
  ggplot2::labs(
    title = "Twenty-day rolling annualized volatility",
    x = NULL,
    y = "Volatility"
  ) +
  ggplot2::theme_minimal()

volatility_chart

ggplot2::ggsave("figures/rolling_volatility.png", volatility_chart, width = 11, height = 8)

17 Automatically generated findings

best_return <- risk_metrics %>% dplyr::slice_max(annualized_return, n = 1)
best_sharpe <- risk_metrics %>% dplyr::slice_max(sharpe_ratio, n = 1)
highest_risk <- risk_metrics %>% dplyr::slice_max(annualized_volatility, n = 1)
worst_drawdown <- risk_metrics %>% dplyr::slice_min(max_drawdown, n = 1)

cat(
  paste0(
    "The investment with the highest annualized return was **", best_return$ticker,
    "** at approximately **", scales::percent(best_return$annualized_return, accuracy = 0.1),
    "**. The strongest risk-adjusted result based on the Sharpe ratio was **",
    best_sharpe$ticker, "**. **", highest_risk$ticker,
    "** had the highest annualized volatility, while **", worst_drawdown$ticker,
    "** experienced the largest maximum drawdown. The hypothetical portfolio ended at **",
    scales::dollar(dplyr::last(portfolio_performance$portfolio_value)),
    "**, compared with a starting value of **", scales::dollar(initial_investment),
    "**. These results describe historical performance and should not be treated as investment advice."
  )
)

The investment with the highest annualized return was NVDA at approximately 39.4%. The strongest risk-adjusted result based on the Sharpe ratio was JPM. NVDA had the highest annualized volatility, while NVDA experienced the largest maximum drawdown. The hypothetical portfolio ended at $15,818.21, compared with a starting value of $10,000. These results describe historical performance and should not be treated as investment advice.

18 Deliverables created

output_files <- list.files("output", pattern = "\\.csv$", full.names = TRUE)

tibble::tibble(
  file = basename(output_files),
  size_kb = round(file.info(output_files)$size / 1024, 1)
) %>%
  knitr::kable(caption = "CSV files created for the group")
CSV files created for the group
file size_kb
company_fundamentals.csv 0.0
daily_returns.csv 107.2
data_dictionary.csv 3.7
data_quality_summary.csv 0.4
finance_headlines.csv 0.2
fred_economic_data.csv 14.9
headline_word_counts.csv 0.0
investment_conjoint_coefficients.csv 0.7
investment_conjoint_level_summary.csv 0.5
investment_conjoint_profile_predictions.csv 2.7
investment_conjoint_quality_summary.csv 0.2
investment_conjoint_response_quality.csv 0.4
investment_conjoint_responses_clean.csv 8.0
investment_conjoint_variable_documentation.csv 0.9
portfolio_performance.csv 43.2
risk_metrics.csv 1.0
stock_prices.csv 258.9
technical_indicators.csv 484.7

19 Conclusion

This report demonstrates the full data-collection and preprocessing role in one reproducible workflow, including investment conjoint-survey cleaning and analysis, REST API requests with httr, JSON processing with jsonlite, market and economic retrieval with quantmod, HTML scraping with rvest, data cleaning with dplyr, and export of reusable CSV datasets. The outputs can be shared with group members for visualization, modeling, forecasting, or presentation work.