Standard Event Impact
Executive Summary

Executive Summary

Did the event move Close? The measured gap vs its expected path.

Pre-Event Points
2041
Post-Event Points
375
Average Effect
-79.09
Relative Effect (%)
-20.8
Cumulative Effect
-29658
Points Above Band (%)
4
Points Outside Band (%)
53.1
Close ran 20.8% below its expected path after the event — an average of -79.09 per day and a cumulative -29,658 across the 375 post-event days. 199 of 375 post-event points fall outside the 95% prediction band (15 above, 184 below), and the average-effect 95% interval (-83.34 to -74.84) excludes zero, so the data are consistent with a real impact of the event. This is an observational before/after comparison: anything else that changed around the same time (seasonal shifts, other campaigns, pricing, market conditions) could contribute to the gap, so read the result as consistent with an effect of the event rather than proof of it.
What this means

The short answer

Tesla's stock close fell about 20.8% below its expected path in the 375 days after the August 7, 2018 "funding secured" tweet, averaging -79.09 per day for a cumulative loss of -29,658. The 95% confidence band excludes zero, meaning the pattern is consistent with a real effect of the event rather than random noise.

The detail

Pre-event baseline: 2041 days of trading data fit a trend plus day-of-week adjustments (R-squared 0.863), establishing the expected path. Post-event window: 375 days. The average daily deviation was -79.09, with 199 of 375 post-event points falling outside the 95% prediction band (184 below, 15 above). The 95% interval for the average effect is -83.34 to -74.84, which excludes zero. A Welch t-test comparing post-event gaps to pre-event residual noise yielded p < 0.001, confirming the post-event pattern deviates from ordinary day-to-day variation.

What this can't tell you

This is an observational before-and-after design: the measured gap is consistent with an effect of the tweet, but concurrent changes—regulatory developments, broader market shifts, Tesla's own operational or financial announcements—could have contributed to or driven the shortfall. The pre-event residual SD of 41.846 means daily movements smaller than that are difficult to isolate from noise; the -79.09 average is large enough to stand apart, but individual days remain noisy.

Overview

Analysis Overview

Interrupted time-series measurement of the event's impact on Close.

N Pre2041
N Post375
Frequencydaily
Seasonalityday-of-week adjustments
What this means

The short answer

The expected path for Tesla's stock was estimated from 2041 pre-event days of data using a linear trend and day-of-week adjustments, then projected forward across the post-event window. The effect is the gap between what actually happened and what the pre-event pattern would predict — this matters because the stock was already rising (+0.123 per day) before the event.

The detail

An ordinary least squares model fit a time trend and day-of-week effects to all 2041 pre-event daily observations, then projected that model across the 375 post-event days with 95% prediction intervals. The effect is the vertical distance between actual Close values and the expected path. This approach avoids the trap of naive before/after averaging, which would falsely credit the pre-existing upward drift to the event. The 95% intervals on the average and cumulative effect assume post-event errors are independent; if autocorrelation is present, the true uncertainty is wider than reported.

What this can't tell you

The counterfactual assumes the pre-event trend and day-of-week patterns would have continued unchanged in the absence of the event — a strong assumption in an active market where other events, regulatory changes, or investor sentiment shifts could alter the trajectory. A longer pre-event window or external control series would strengthen confidence in this assumption.

Data Preparation

Data Preparation

How the raw rows became a before/after time series.

Initial Rows2416
Final Rows2416
N Duplicate Rows0
N Dropped0
Pre Points2041
Post Points375
Frequencydaily
What this means

The short answer

All 2,416 rows loaded without duplicates or gaps; each date appeared once. The 'After tweet' label in the Period column was assigned as the post-event marker (August 7, 2018 onward), yielding 2041 pre-event days and 375 post-event days.

The detail

The raw dataset contained 2,416 rows with no duplicate dates, so no aggregation was needed. The cadence was inferred as daily from the median gap between consecutive dates. In the Period column, 'After tweet' was treated as the post-event label and 'Before tweet' as pre-event because 'After tweet' dates fall later on average and neither is a standard before/after keyword. This assignment produced 2041 pre-event observations (before August 7, 2018) and 375 post-event observations (August 7, 2018 onward), with the event date fixed at 2018-08-07.

What this can't tell you

The Period labels are domain-specific and required inference about their meaning; if the original labeling scheme differs from this interpretation, the pre/post split would shift. Consider verifying the label assignment against source documentation if available.

Visualization

Actual vs Expected Path

Close over time against its pre-event counterfactual.

What this means

The short answer

From the chart, Tesla's actual Close price (shown as individual points) tracks well above its expected counterfactual path early in the pre-event window, then diverges sharply downward after day 2961 (August 7, 2018). The post-event points cluster consistently below the expected line, with the vertical gap representing the measured effect.

The detail

The chart plots actual Close (points) against the counterfactual expected path (line) across the full time span, with the event occurring at day 2961. Pre-event, the actual series follows the fitted trend closely, validating the model fit. After day 2961, the actual points pull away below the expected series, creating a sustained negative gap. The average vertical distance of -79.09 per day is the measured effect; 184 of 375 post-event points fall below the 95% prediction band, while only 15 fall above it, confirming the downward shift is not random scatter.

What this can't tell you

The chart shows association between timing and price movement but does not isolate the tweet's causal role from other concurrent events (regulatory announcements, earnings surprises, market-wide volatility) that may have occurred around August 7, 2018. A visual pattern alone cannot separate the tweet's effect from coincident shocks.

Data Table

Effect Size

The event's measured effect: per period, relative, and cumulative, with 95% intervals.

MeasureValueCI LowCI HighInterpretation
Average effect per day-79.09-83.34-74.84Actual Close minus its expected value, averaged over the post-event window; the 95% interval excludes zero
Relative effect (% of expected level)-20.8-21.9-19.7The average gap as a share of the expected post-event level (380.23)
Cumulative effect over 375 post-event days-29658-3.125e+04-2.806e+04Total Close gained (positive) or lost (negative) relative to the expected path
Post-event points outside 95% band (%)53.1199 of 375 post-event points escape the band (15 above, 184 below); under a correct no-effect model about 5% would
What this means

The short answer

Tesla's stock fell an average of -79.09 per day below its expected path in the 375 days after August 7, 2018 — a relative decline of 20.8% — with a 95% confidence interval (-83.34 to -74.84) that excludes zero. Cumulatively, the stock underperformed its expected path by -29,658 over the post-event window.

The detail

The average effect per day is -79.09 (95% CI -83.34 to -74.84), meaning actual Close fell that much below the predicted level on a typical post-event day. Relative to the expected post-event level (380.23), this represents -20.8% (95% CI -21.9 to -19.7). Over 375 post-event days, the cumulative effect is -29,658 (95% CI -31,252.7 to -28,063.3). The key diagnostic: 199 of 375 post-event points (53.1%) fall outside the 95% prediction band, with 184 below and only 15 above. Under a correct no-effect model, about 5% of points would escape the band by chance alone. The interval exclusion of zero and the band-escape rate both support a real departure from the expected path.

What this can't tell you

These intervals assume post-event errors are independent; if the residuals are autocorrelated, the true uncertainty is wider. The effect size describes this observed 375-day window and does not forecast beyond it. Attribution to the tweet alone requires ruling out concurrent market shocks, regulatory actions, or company announcements — data not present here.

Data Table

Model Check

How trustworthy the counterfactual is.

MetricValueInterpretation
Pre-event model R-squared0.863Share of pre-event variation in Close explained by the trend plus day-of-week adjustments — a strong counterfactual
Pre-event residual SD41.846Typical day-level wiggle of Close around the pre-event fit, in the metric's own units
Post vs pre residual t-test p-value< 0.001Welch t-test comparing post-event gaps to pre-event residuals: p < 0.001 — the post-event points deviate from the pre-event noise pattern
Seasonality adjustmentDay-of-week adjustments — the series is daily with 2041 pre-event days — enough to estimate day-of-week effects
What this means

The short answer

The counterfactual is strong: the pre-event model explains 86.3% of Close's variation, and post-event deviations are large enough to stand apart from ordinary noise (p < 0.001).

The detail

Pre-event R-squared is 0.863, meaning the trend plus day-of-week adjustments captured the bulk of the pre-event pattern. Pre-event residual SD is 41.846—the typical daily wiggle around the fitted path. The Welch t-test comparing post-event gaps to this pre-event noise distribution yields p < 0.001, confirming the post-event shortfall is not a random fluctuation. Seasonality was adjusted via day-of-week effects, feasible with 2041 pre-event trading days.

What this can't tell you

A high R-squared and a significant residual test do not rule out confounding: other events or conditions that shifted around August 2018 could explain part of the observed gap. The model captures trend and day-of-week seasonality only; it does not account for volatility spikes, regime changes, or event-specific shocks that the tweet itself may have triggered.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

Event Impact — Did the Change Move the Metric?

Interrupted time-series analysis: the user marks which rows fall before and after an event (launch, price change, campaign). A counterfactual — the expected path of the metric had nothing changed — is fitted on the pre-event rows alone (linear trend + day-of-week or month-of-year adjustments when the history supports them), projected across the post-event window with 95% prediction intervals, and the effect is the gap between actual and expected: average per period, relative %, and cumulative, each with a 95% interval.

Why This Method?

A naive before/after comparison of averages credits any pre-existing trend to the event. The counterfactual subtracts the path the metric was already on, which is the honest base-R answer to "causal impact". Base R only (stats::lm + prediction intervals); no CausalImpact/bsts.

What This Analysis Covers

  • Actual vs expected counterfactual path (chart)
  • Average / relative / cumulative effect with 95% intervals
  • Share of post-event points outside the 95% prediction band
  • Model diagnostics (pre-event R-squared, residual t-test, seasonality)

Honest Framing

This is OBSERVATIONAL: the result can be consistent with an effect of the event, never proof of it — concurrent changes can contribute.

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {date, value, period}. All narrative is derived from the user's own column names and computed values.

suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))

Core Analysis Pipeline

Step 1: Parse dates robustly (init() may already have Date-coerced)

raw_date <- df$date
  non_blank <- !(is.na(raw_date) | !nzchar(trimws(as.character(raw_date))))
  d <- parse_dates_robust(raw_date)
  n_nonblank <- sum(non_blank)
  if (n_nonblank == 0) {
    stop(sprintf("The &#x27;%s' column is empty — no dates to anchor the event to.", date_name))
  }
  n_unparsed <- sum(non_blank & is.na(d))
  if (n_unparsed / n_nonblank > 0.05) {
    stop(sprintf(
      "%d of %d values in &#x27;%s' (%.0f%%) could not be read as dates. Please use a recognizable date format (e.g. 2024-01-31 or 01/31/2024).",
      n_unparsed, n_nonblank, date_name, 100 * n_unparsed / n_nonblank))
  }

Step 2: Coerce the metric (95% rule)

v <- df$value
  if (!is.numeric(v)) {
    conv <- suppressWarnings(as.numeric(as.character(v)))
    n_orig <- sum(!is.na(v) & nzchar(as.character(v)))
    if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
      v <- conv
    } else {
      stop(sprintf(
        "The &#x27;%s' column is not numeric — measuring an event impact needs a numeric metric.",
        value_name))
    }
  }
  v <- as.numeric(v)

Step 3: Binarize the period column — exactly 2 levels; the after

level matches (after|post|1|true|treatment|new) case-insensitive, else the level whose dates fall later on average. The choice is stated.

per <- trimws(as.character(df$period))
  per[is.na(df$period) | !nzchar(per)] <- NA_character_

  keep <- !is.na(d) & !is.na(v) & !is.na(per)
  n_dropped_rows <- initial_rows - sum(keep)
  work <- data.frame(date = d[keep], value = v[keep], period = per[keep],
                     stringsAsFactors = FALSE)

  levs <- sort(unique(work$period))
  if (length(levs) != 2) {
    stop(sprintf(
      "The &#x27;%s' column must contain exactly 2 labels marking before vs after the event — found %d (%s).",
      period_name, length(levs), paste(head(levs, 5), collapse = ", ")))
  }

  after_regex <- "^(after|post|1|true|treatment|new)$"
  is_after <- grepl(after_regex, levs, ignore.case = TRUE)
  if (sum(is_after) == 1) {
    after_level <- levs[is_after]
    after_reason <- sprintf(
      "&#x27;%s' matches a standard after-the-event label (after/post/1/true/treatment/new)",
      after_level)
  } else {
    mean_dates <- tapply(as.numeric(work$date), work$period, mean)
    after_level <- names(mean_dates)[which.max(mean_dates)]
    after_reason <- sprintf(
      "neither label is a standard before/after word, and &#x27;%s' dates fall later on average",
      after_level)
  }
  pre_level <- setdiff(levs, after_level)

Step 4: Aggregate duplicate dates by SUM within each period group

(rows treated as amounts that add up within a period — stated in prose)

agg <- aggregate(value ~ date + period, data = work, FUN = sum)
  n_dup_rows <- nrow(work) - nrow(agg)
  both <- intersect(agg$date[agg$period == after_level],
                    agg$date[agg$period == pre_level])
  if (length(both) > 0) {
    stop(sprintf(
      "%d %s in &#x27;%s' %s marked both '%s' and '%s' in '%s' — every date must fall entirely before or after the event.",
      length(both), pl(length(both), "date"), date_name,
      pl(length(both), "is", "are"), pre_level, after_level, period_name))
  }
  agg <- agg[order(agg$date), , drop = FALSE]

  pre  <- agg[agg$period == pre_level, , drop = FALSE]
  post <- agg[agg$period == after_level, , drop = FALSE]
  n_pre <- nrow(pre); n_post <- nrow(post)
  if (n_pre < 8) {
    stop(sprintf(
      "Only %d %s before the event(&#x27;%s' in '%s') — at least 8 pre-event points are needed to build a counterfactual for '%s'.",
      n_pre, pl(n_pre, "point"), pre_level, period_name, value_name))
  }
  if (n_post < 3) {
    stop(sprintf(
      "Only %d %s after the event(&#x27;%s' in '%s') — at least 3 post-event points are needed to measure an impact on '%s'.",
      n_post, pl(n_post, "point"), after_level, period_name, value_name))
  }

  event_date <- min(post$date)
  n_interleaved <- sum(pre$date >= event_date)

Step 5: Infer cadence from the median date gap

gaps <- as.numeric(diff(sort(unique(agg$date))))
  med_gap <- median(gaps)
  if (med_gap <= 1.5) {
    freq_label <- "daily";   period_word <- "day";   step_days <- 1
  } else if (med_gap >= 5.5 && med_gap <= 8.5) {
    freq_label <- "weekly";  period_word <- "week";  step_days <- 7
  } else if (med_gap >= 26 && med_gap <= 35) {
    freq_label <- "monthly"; period_word <- "month"; step_days <- 30.44
  } else {
    step_days <- max(1, round(med_gap)); freq_label <- "irregular"
    period_word <- sprintf("%d-day period", round(step_days))
  }

Step 6: Time index (days since first date) + seasonality choice:

day-of-week dummies if daily with >=21 pre points (3 full weeks), month dummies if monthly with >=24 pre points, else trend-only.

t0 <- min(agg$date)
  pre$tidx  <- as.numeric(pre$date - t0)
  post$tidx <- as.numeric(post$date - t0)
  event_tidx <- min(post$tidx)

  seasonality <- "none"
  season_reason <- ""
  if (freq_label == "daily") {
    if (n_pre >= 21) {
      seasonality <- "dow"
    } else {
      season_reason <- sprintf(
        "only %d pre-event days are available and 3 full weeks are needed, so day-of-week effects were not estimated",
        n_pre)
    }
  } else if (freq_label == "monthly") {
    if (n_pre >= 24) {
      seasonality <- "month"
    } else {
      season_reason <- sprintf(
        "only %d pre-event months are available and 24 are needed, so month-of-year effects were not estimated",
        n_pre)
    }
  } else {
    season_reason <- sprintf(
      "the series cadence is %s, so only the trend is modelled", freq_label)
  }

  if (seasonality != "none") {
    fmt_code <- if (seasonality == "dow") "%u" else "%m"
    pre$season  <- format(pre$date, fmt_code)
    post$season <- format(post$date, fmt_code)
    if (!all(unique(post$season) %in% unique(pre$season))) {
      seasonality <- "none"
      season_reason <- "the post-event window contains calendar positions never seen before the event, so seasonal adjustment was dropped"
    } else if ((n_pre - 1 - length(unique(pre$season))) < 3) {
      seasonality <- "none"
      season_reason <- "too few pre-event points remain once seasonal terms are added, so seasonal adjustment was dropped"
    } else {
      season_reason <- if (seasonality == "dow") {
        sprintf("the series is daily with %d pre-event days — enough to estimate day-of-week effects", n_pre)
      } else {
        sprintf("the series is monthly with %d pre-event months — enough to estimate month-of-year effects", n_pre)
      }
    }
  }
  seasonality_label <- switch(seasonality,
                              dow   = "day-of-week adjustments",
                              month = "month-of-year adjustments",
                              none  = "no seasonal adjustment")

Step 7: Fit the counterfactual on PRE only; project over POST with

95% prediction intervals

fml <- if (seasonality != "none") value ~ tidx + season else value ~ tidx
  fit <- lm(fml, data = pre)
  pr <- suppressWarnings(
    predict(fit, newdata = post, interval = "prediction", level = 0.95))
  if (any(!is.finite(pr))) {
    stop(sprintf(
      "The pre-event model for &#x27;%s' could not produce finite predictions over the post-event window — the pre-event history may be too sparse.",
      value_name))
  }
  expected <- as.numeric(pr[, "fit"])
  lwr <- as.numeric(pr[, "lwr"])
  upr <- as.numeric(pr[, "upr"])

Step 9: Diagnostics — pre-event fit quality + t-test of post gaps

vs pre residuals

resid_pre <- as.numeric(residuals(fit))
  r2 <- suppressWarnings(summary(fit)$r.squared)
  sd_pre <- sd(resid_pre)
  p_t <- tryCatch(stats::t.test(gap, resid_pre)$p.value,
                  error = function(e) NA_real_)

  significant <- all(is.finite(ci_avg)) && (ci_avg[1] > 0 || ci_avg[2] < 0)
  direction_word <- if (avg_effect >= 0) "above" else "below"
  verdict <- if (significant) {
    "consistent with a real impact of the event"
  } else {
    "not consistent with a clearly measurable impact of the event"
  }

Step 10: Round ONCE — the same rounded numbers feed both the

tables and every sentence that quotes them.

avg_r  <- round(avg_effect, 2)
  avg_lo <- round(ci_avg[1], 2); avg_hi <- round(ci_avg[2], 2)
  rel_r  <- if (is.na(rel_effect)) NA_real_ else round(rel_effect, 1)
  rel_lo <- if (is.na(ci_rel[1])) NA_real_ else round(ci_rel[1], 1)
  rel_hi <- if (is.na(ci_rel[2])) NA_real_ else round(ci_rel[2], 1)
  cum_r  <- round(cum_effect, 1)
  cum_lo <- round(ci_cum[1], 1); cum_hi <- round(ci_cum[2], 1)
  pct_out_r   <- round(100 * n_outside / n_post, 1)
  pct_above_r <- round(100 * n_above / n_post, 1)
  r2_r <- if (is.finite(r2)) round(r2, 3) else NA_real_
  sd_pre_r <- if (is.finite(sd_pre)) round(sd_pre, 3) else NA_real_
  p_tab <- if (is.na(p_t)) NA_real_ else max(round(signif(p_t, 3), 4), 0.001)
  trend_per_period <- round(as.numeric(coef(fit)[["tidx"]]) * step_days, 3)
  baseline_r <- round(baseline, 2)
Your data has more stories to tell.Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing