Do FDA Approvals Move Stock Prices?

A case study on Cumulative Abnormal Returns in biotech

2025-11-04

   

TL;DR

  • FDA approval announcements are associated with positive average CAR across events.
  • Most of the impact accrues in the first 1–3 trading days.
  • Trading volume typically spikes, but it doesn’t reliably predict the direction or magnitude of price moves.
  • Results use a constant‑mean abnormal return model with a 120‑day estimation window, 10‑day buffer, and 15‑day event window.

Key stats at a glance

  • 66 FDA approvals (Apr–Oct 2025) with verified price histories
  • Mean CAR 6.9% | Median 3.7% | Std dev 18.9%
  • Baseline: 120 trading days pre-announcement, 10-day buffer, 15-day event window

When the FDA approves a new drug, investors react. But how much do stock prices actually move? And more importantly, are those movements predictable enough to matter?

To answer this, I analyzed 66 FDA approval announcements from April to October 2025. For each approval, I measured the Cumulative Abnormal Return (CAR)—the stock's performance minus what we'd expect based on its historical volatility.

Study Snapshot

ComponentDetails
Data sourceBioAPI approval announcements (Drugs.com feed)
Price dataYahoo Finance (adjusted close)
Sample size66 approvals (Apr–Oct 2025)
Baseline window120 trading days before announcement
Buffer10 trading days between baseline and event windows
Event window15 trading days after announcement
MetricCumulative Abnormal Return (actual return − baseline average)
1# Install dependencies if needed
2%pip install requests yfinance matplotlib pandas numpy seaborn scipy tabulate
1# Imports and matplotlib configuration
2import requests
3import re
4import os
5import time
6from datetime import datetime, timedelta, timezone
7import pandas as pd
8import numpy as np
9import yfinance as yf
10...
1# Imports and matplotlib configuration
2import requests
3import re
4import os
5import time
6from datetime import datetime, timedelta, timezone
7import pandas as pd
8import numpy as np
9import yfinance as yf
10import matplotlib.pyplot as plt
11from IPython.display import Markdown
12import seaborn as sns
13
14%matplotlib inline
15sns.set_style("whitegrid")
16plt.rcParams['figure.figsize'] = (12, 6)
17plt.rcParams['font.size'] = 11
1# Event study configuration
2DAYS_BEFORE_EVENT = 120  # estimation window length
3BUFFER_DAYS = 10         # gap between estimation and event windows
4DAYS_AFTER_EVENT = 15    # event window length

Step 1: Fetch FDA Approval Announcements

First, I'll fetch approval announcements from BioAPI.dev, which aggregates drug approval news.

1# Configure API access
2API_URL = "https://api.bioapi.dev"
3API_KEY = "your-bio-api-key"
4
5def to_datetime(date_string):
6    """Convert ISO date string to datetime object"""
7    dt = datetime.fromisoformat(date_string.replace('Z', '+00:00'))
8    return dt
9
10# Fetch approval news
11response = requests.get(
12    f"{API_URL}/v1/database/new-drug-approvals/news/drugs-com",
13    headers={
14        "X-api-key": API_KEY,
15        "Content-Type": "application/json"
16    },
17    params={"limit": 1000}
18)
19
20response.raise_for_status()
21result = response.json()
22result['data'].sort(key=lambda x: to_datetime(x['pub_date']), reverse=True)
23
24print(f"Fetched {len(result['data'])} approval announcements")
Fetched 138 approval announcements

Step 2: Extract Stock Tickers

I'll parse the announcements to extract stock tickers (NASDAQ/NYSE only).

1def find_ticker(text):
2    """Extract stock ticker and exchange from text"""
3    PATTERN = re.compile(
4        r"""\(
5            (?P<exchange>[A-Z]{2,10}) \s*:\s*
6            (?P<ticker>[A-Z]{1,5}(?:[.\-][A-Z0-9]{1,2})?)
7            [^)]*
8        \)""",
9        re.IGNORECASE | re.VERBOSE
10...
1def find_ticker(text):
2    """Extract stock ticker and exchange from text"""
3    PATTERN = re.compile(
4        r"""\(
5            (?P<exchange>[A-Z]{2,10}) \s*:\s*
6            (?P<ticker>[A-Z]{1,5}(?:[.\-][A-Z0-9]{1,2})?)
7            [^)]*
8        \)""",
9        re.IGNORECASE | re.VERBOSE
10    )
11    for match in PATTERN.finditer(text):
12        m = match.groupdict()
13        if m:
14            yield m
15
16# Extract items with valid tickers
17items_with_ticker = []
18for item in result['data']:
19    tickers = list(find_ticker(item['title'])) + list(find_ticker(item['description']))
20    if len(tickers) > 0:
21        node = {
22            "tickers": tickers,
23            "published_at": to_datetime(item['pub_date']),
24            "fetched_at": to_datetime(item['meta_fetched_at']),
25            "title": item['title']
26        }
27        items_with_ticker.append(node)
28
29print(f"Found {len(items_with_ticker)} announcements with stock tickers")
Found 74 announcements with stock tickers

Step 3: Fetch Stock Price Data

Using yfinance, I'll fetch historical prices for each stock. I need prices from 120 days before the announcement (to establish a baseline) through 15 days after (the event window).

Using adjusted close prices where available helps account for splits and dividends.

1# Set up price cache directory
2CACHE_DIR = "price_cache"
3os.makedirs(CACHE_DIR, exist_ok=True)
4
5def fetch_stock_data(ticker, pivot_date, exchange="", days_before=365, days_after=None):
6    """Fetch stock price data with caching"""
7    cache_file = f"{CACHE_DIR}/{ticker.upper()}_{exchange.upper()}.csv"
8    
9    # Check cache first
10...
1# Set up price cache directory
2CACHE_DIR = "price_cache"
3os.makedirs(CACHE_DIR, exist_ok=True)
4
5def fetch_stock_data(ticker, pivot_date, exchange="", days_before=365, days_after=None):
6    """Fetch stock price data with caching"""
7    cache_file = f"{CACHE_DIR}/{ticker.upper()}_{exchange.upper()}.csv"
8    
9    # Check cache first
10    if os.path.exists(cache_file):
11        data = pd.read_csv(cache_file, index_col=0)
12        data.index = pd.to_datetime(data.index, utc=True)
13        return data
14    
15    # Fetch from yfinance
16    print(f"Fetching {ticker} from yfinance...")
17    start_date = pivot_date - timedelta(days=days_before)
18    end_date = pivot_date + timedelta(days=days_after) if days_after else datetime.now()
19    
20    time.sleep(0.5)  # Be respectful to the API
21    data = yf.Ticker(ticker).history(start=start_date, end=end_date)
22
23    # Ensure timezone-aware UTC index
24    try:
25        if data.index.tz is None:
26            data.index = data.index.tz_localize('UTC')
27        else:
28            data.index = data.index.tz_convert('UTC')
29    except Exception:
30        pass
31    
32    # Save to cache
33    data.to_csv(cache_file, index=True)
34    return data
35
36# Fetch price data for all events (only if at least 15 days have passed)
37for item in items_with_ticker:
38    if datetime.now(timezone.utc) - item['published_at'] < timedelta(days=15):
39        continue  # Skip recent events
40    
41    for ticker in item['tickers']:
42        if ticker['exchange'].lower() in ['nasdaq', 'nyse']:
43            try:
44                data = fetch_stock_data(ticker['ticker'], item['published_at'], 
45                                       exchange=ticker['exchange'])
46                item['price'] = data
47                break  # Only need one ticker per event
48            except Exception as e:
49                print(f"Error fetching {ticker['ticker']}: {e}")
50
51events_with_data = [item for item in items_with_ticker if 'price' in item]
52print(f"Successfully fetched price data for {len(events_with_data)} events")
Successfully fetched price data for 66 events

Step 4: Calculate Cumulative Abnormal Returns (CAR)

For each event, I calculate:

  1. Baseline return: Average daily return over 120 days before the announcement
  2. Event window returns: Daily returns for 15 days after the announcement
  3. Abnormal returns: Event returns minus the baseline
  4. Cumulative Abnormal Return (CAR): Sum of all abnormal returns in the event window
1def calculate_car(market_data, event_date, days_after_event=15, buffer_days=10, days_before_event=120):
2    """Calculate Cumulative Abnormal Returns for an event"""
3    
4    # Find closest date to event (market might be closed)
5    def closest_date(date):
6        if date.tzinfo is None:
7            date = date.replace(tzinfo=market_data.index.tz)
8        closest = market_data[market_data.index >= date].index
9        return closest[0] if len(closest) > 0 else market_data.index[-1]
10...
1def calculate_car(market_data, event_date, days_after_event=15, buffer_days=10, days_before_event=120):
2    """Calculate Cumulative Abnormal Returns for an event"""
3    
4    # Find closest date to event (market might be closed)
5    def closest_date(date):
6        if date.tzinfo is None:
7            date = date.replace(tzinfo=market_data.index.tz)
8        closest = market_data[market_data.index >= date].index
9        return closest[0] if len(closest) > 0 else market_data.index[-1]
10    
11    # Define windows
12    base_end_date = closest_date(event_date - timedelta(days=buffer_days))
13    base_start_date = closest_date(event_date - timedelta(days=days_before_event))
14    
15    event_start_date = closest_date(event_date)
16    event_end_date = closest_date(event_date + timedelta(days=days_after_event))
17    
18    # Calculate daily returns
19    price_col = 'Adj Close' if 'Adj Close' in market_data.columns else 'Close'
20    market_data['daily_return'] = market_data[price_col].pct_change() * 100
21    
22    # Baseline window stats
23    base_data = market_data.loc[base_start_date:base_end_date]
24    base_avg_return = base_data['daily_return'].mean()
25    
26    # Event window stats
27    event_data = market_data.loc[event_start_date:event_end_date]
28    
29    # Calculate abnormal returns
30    abnormal_returns = event_data['daily_return'] - base_avg_return
31    car = abnormal_returns.sum()
32    
33    # Volume analysis
34    base_avg_volume = base_data['Volume'].mean()
35    event_avg_volume = event_data['Volume'].mean()
36    volume_ratio = event_avg_volume / base_avg_volume if base_avg_volume > 0 else np.nan
37    
38    return {
39        'car': car,
40        'absolute_abnormal_returns': abnormal_returns.abs().sum(),
41        'volume_ratio': volume_ratio,
42        'daily_abnormal_returns': abnormal_returns.tolist(),
43        'n_event_days': len(event_data)
44    }
45
46# Calculate CAR for all events
47results = []
48for item in events_with_data:
49    try:
50        car_result = calculate_car(
51            item['price'],
52            item['published_at'],
53            days_after_event=DAYS_AFTER_EVENT,
54            buffer_days=BUFFER_DAYS,
55            days_before_event=DAYS_BEFORE_EVENT
56        )
57        results.append({
58            'ticker': item['tickers'][0]['ticker'],
59            'published_at': item['published_at'],
60            'title': item['title'],
61            **car_result
62        })
63    except Exception as e:
64        print(f"Error calculating CAR for {item['tickers'][0]['ticker']}: {e}")
65
66df = pd.DataFrame(results)
67
68summary = pd.DataFrame([
69    ("Events analyzed", len(df)),
70    ("Mean CAR", f"{df['car'].mean():.2f}%"),
71    ("Median CAR", f"{df['car'].median():.2f}%"),
72    ("Standard deviation", f"{df['car'].std():.2f}%")
73])
74
75Markdown(summary.to_markdown(index=False, headers=[""]))
Events analyzed66
Mean CAR6.87%
Median CAR3.66%
Standard deviation18.91%

What it shows: Summary stats for the full event set—66 approvals—so you see the sample size, the average uplift (mean/median CAR), and how widely results swing (standard deviation).

Implication: The mean 6–7% boost is tempting, but the nearly 19% standard deviation reminds you that approvals are volatile; portfolio sizing should account for that spread rather than the average alone.

Finding 1: On Average, Approvals Generate Significant Positive Returns

The data shows a clear positive drift following FDA approvals. But when does this reaction happen, and is it statistically meaningful?

1# Visualization 1: Distribution of CARs
2fig, ax = plt.subplots(figsize=(14, 7))
3
4# Create histogram
5n, bins, patches = ax.hist(df['car'], bins=25, edgecolor='black', alpha=0.7)
6
7# Color bars based on positive/negative
8for i, patch in enumerate(patches):
9    if bins[i] < 0:
10...
1# Visualization 1: Distribution of CARs
2fig, ax = plt.subplots(figsize=(14, 7))
3
4# Create histogram
5n, bins, patches = ax.hist(df['car'], bins=25, edgecolor='black', alpha=0.7)
6
7# Color bars based on positive/negative
8for i, patch in enumerate(patches):
9    if bins[i] < 0:
10        patch.set_facecolor('#e74c3c')  # Red for negative
11    else:
12        patch.set_facecolor('#2ecc71')  # Green for positive
13
14# Add statistics lines
15mean_car = df['car'].mean()
16median_car = df['car'].median()
17ax.axvline(mean_car, color='navy', linestyle='--', linewidth=2.5, 
18          label=f'Mean: {mean_car:.1f}%', zorder=5)
19ax.axvline(median_car, color='darkgreen', linestyle='--', linewidth=2.5,
20          label=f'Median: {median_car:.1f}%', zorder=5)
21ax.axvline(0, color='gray', linestyle='-', linewidth=1, alpha=0.5, zorder=4)
22
23# Labels and formatting
24ax.set_xlabel('Cumulative Abnormal Return (%)', fontsize=13, fontweight='bold')
25ax.set_ylabel('Number of Events', fontsize=13, fontweight='bold')
26ax.set_title('How FDA Approvals Impact Stock Prices\nDistribution of 15-Day Cumulative Abnormal Returns', 
27            fontsize=15, fontweight='bold', pad=20)
28ax.legend(fontsize=12, loc='upper left', framealpha=0.9)
29ax.grid(axis='y', alpha=0.3)
30
31# Add annotation box with stats
32positive_pct = (df['car'] > 0).mean() * 100
33negative_pct = (df['car'] < 0).mean() * 100
34textstr = f'Total events: {len(df)}\n'
35textstr += f'Positive: {(df["car"] > 0).sum()} ({positive_pct:.0f}%)\n'
36textstr += f'Negative: {(df["car"] < 0).sum()} ({negative_pct:.0f}%)\n'
37textstr += f'Range: {df["car"].min():.1f}% to {df["car"].max():.1f}%'
38
39props = dict(boxstyle='round', facecolor='wheat', alpha=0.8, edgecolor='black', linewidth=1.5)
40ax.text(0.70, 0.97, textstr, transform=ax.transAxes, fontsize=11,
41        verticalalignment='top', bbox=props, family='monospace')
42
43plt.tight_layout()
44plt.show()
45
46summary = pd.DataFrame([
47    ("Mean CAR", f"{mean_car:.2f}%"),
48    ("Median CAR", f"{median_car:.2f}%"),
49    ("Standard deviation", f"{df['car'].std():.2f}%"),
50    ("Min", f"{df['car'].min():.2f}%"),
51    ("Max", f"{df['car'].max():.2f}%"),
52    ("Positive events", f"{(df['car'] > 0).sum()} ({positive_pct:.1f}%)"),
53    ("Negative events", f"{(df['car'] < 0).sum()} ({negative_pct:.1f}%)")
54])
55Markdown(f"**Key Statistics** \n {summary.to_markdown(index=False, headers=[''])}")

png

Key Statistics

Mean CAR6.87%
Median CAR3.66%
Standard deviation18.91%
Min-75.76%
Max81.66%
Positive events46 (69.7%)
Negative events20 (30.3%)

Figure 1. Distribution of 15-day cumulative abnormal returns. What it shows: Each bar counts approval events by their 15-day CAR, so you can see how often gains vs. losses occur and how fat the tails are on both sides.

Implication: Positive abnormal returns dominate but the spread is wide; sizing around approvals needs to respect that a few extreme winners pull up the average while sizable drawdowns still occur.

Statistical Significance

Let me test whether this positive average is statistically significant or could be due to chance:

1# Statistical significance testing
2from scipy import stats
3from scipy.stats import wilcoxon
4
5# T-test: Is mean CAR significantly different from zero?
6t_statistic, p_value = stats.ttest_1samp(df['car'].dropna(), 0)
7
8# Nonparametric robustness: Wilcoxon signed-rank test
9w_stat, p_w = wilcoxon(df['car'].dropna())
10...
1# Statistical significance testing
2from scipy import stats
3from scipy.stats import wilcoxon
4
5# T-test: Is mean CAR significantly different from zero?
6t_statistic, p_value = stats.ttest_1samp(df['car'].dropna(), 0)
7
8# Nonparametric robustness: Wilcoxon signed-rank test
9w_stat, p_w = wilcoxon(df['car'].dropna())
10
11# Calculate confidence interval
12n = len(df['car'].dropna())
13mean_car = df['car'].mean()
14std_car = df['car'].std()
15se_car = std_car / np.sqrt(n)
16confidence_level = 0.95
17degrees_freedom = n - 1
18confidence_interval = stats.t.interval(confidence_level, degrees_freedom, mean_car, se_car)
19
20summary_df = pd.DataFrame([
21    ("Sample size (n)", n),
22    ("Mean CAR", f"{mean_car:.2f}%"),
23    ("Standard Error", f"{se_car:.2f}%"),
24    ("t-statistic", f"{t_statistic:.3f}"),
25    ("p-value", f"{p_value:.4f}"),
26    ("Wilcoxon signed-rank p-value", f"{p_w:.4f}"),
27    ("95% Confidence Interval", f"[{confidence_interval[0]:.2f}%, {confidence_interval[1]:.2f}%]")
28])
29summary = f"""
30### Statistical Significance Test
31--
32Null Hypothesis: Mean CAR = 0% \n
33Alternative: Mean CAR ≠ 0% \n
34
35**Sample Statistics:**
36{summary_df.iloc[0:3].to_markdown(index=False, headers=[''])}
37
38**Statistical Significance:**
39{summary_df.iloc[3:].to_markdown(index=False, headers=[''])} \n
40"""
41
42if p_value < 0.05:
43    summary += f""" > ✓ STATISTICALLY SIGNIFICANT at α=0.05 we can reject the null hypothesis. The positive CAR is unlikely to be due to random chance."""
44else:
45    summary += f""" > ✗ NOT statistically significant at α=0.05 we cannot reject the null hypothesis."""
46
47Markdown(summary)

Statistical Significance Test


Null Hypothesis: Mean CAR = 0%

Alternative: Mean CAR ≠ 0%

Sample Statistics:

Sample size (n)66
Mean CAR6.87%
Standard Error2.33%

Statistical Significance:

t-statistic2.953
p-value0.0044
Wilcoxon signed-rank p-value0.0001
95% Confidence Interval[2.23%, 11.52%]

✓ STATISTICALLY SIGNIFICANT at α=0.05 we can reject the null hypothesis. The positive CAR is unlikely to be due to random chance.

What it shows: The t-test and Wilcoxon signed-rank test both compare the observed mean CAR against zero and return p-values well below 0.05, rejecting the idea that the average uplift is mere noise.

Implication: There is statistical backing for an "approval premium" on average, though these tests speak only to the mean; individual trades can still deviate sharply.

Finding 2: Impact is Front-Loaded (First 1–3 Trading Days)

Does the price movement happen immediately on announcement day, or does it build gradually over time?

1# Visualization 2: Daily CAR Progression
2# Calculate average daily returns and cumulative returns across all events
3
4# Create matrix of daily abnormal returns
5max_days = max([len(x) for x in df['daily_abnormal_returns']])
6daily_returns_matrix = []
7
8for returns in df['daily_abnormal_returns']:
9    padded = returns + [np.nan] * (max_days - len(returns))
10...
1# Visualization 2: Daily CAR Progression
2# Calculate average daily returns and cumulative returns across all events
3
4# Create matrix of daily abnormal returns
5max_days = max([len(x) for x in df['daily_abnormal_returns']])
6daily_returns_matrix = []
7
8for returns in df['daily_abnormal_returns']:
9    padded = returns + [np.nan] * (max_days - len(returns))
10    daily_returns_matrix.append(padded)
11
12daily_returns_df = pd.DataFrame(daily_returns_matrix)
13
14# Calculate average daily abnormal return and cumulative average
15avg_daily_ar = daily_returns_df.mean(axis=0)
16avg_cumulative_ar = avg_daily_ar.cumsum()
17
18# Calculate confidence bands
19se_daily = daily_returns_df.sem(axis=0)
20ci_upper = avg_cumulative_ar + 1.96 * se_daily.cumsum()
21ci_lower = avg_cumulative_ar - 1.96 * se_daily.cumsum()
22
23# Create the plot
24fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10))
25
26# Plot 1: Cumulative Average Abnormal Return
27ax1.plot(range(len(avg_cumulative_ar)), avg_cumulative_ar, 
28         linewidth=3, color='#2c3e50', marker='o', markersize=6, label='Average CAR')
29ax1.fill_between(range(len(avg_cumulative_ar)), ci_lower, ci_upper, 
30                  alpha=0.2, color='#3498db', label='95% Confidence Band')
31ax1.axhline(0, color='gray', linestyle='--', linewidth=1, alpha=0.6)
32ax1.set_xlabel('Days Since Announcement', fontsize=12, fontweight='bold')
33ax1.set_ylabel('Cumulative Abnormal Return (%)', fontsize=12, fontweight='bold')
34ax1.set_title('How Returns Accumulate Over Time\nAverage Cumulative Abnormal Return by Day', 
35              fontsize=14, fontweight='bold', pad=15)
36ax1.grid(True, alpha=0.3)
37ax1.legend(fontsize=11, loc='upper left')
38
39# Add annotations for key days
40day_0_car = avg_cumulative_ar.iloc[0]
41day_5_car = avg_cumulative_ar.iloc[5] if len(avg_cumulative_ar) > 5 else avg_cumulative_ar.iloc[-1]
42final_car = avg_cumulative_ar.iloc[-1]
43
44ax1.annotate(f'Day 0: {day_0_car:.2f}%', 
45            xy=(0, day_0_car), xytext=(1, day_0_car + 1),
46            fontsize=10, fontweight='bold',
47            bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.7))
48
49ax1.annotate(f'Day 5: {day_5_car:.2f}%', 
50            xy=(5, day_5_car), xytext=(6, day_5_car + 1),
51            fontsize=10, fontweight='bold',
52            bbox=dict(boxstyle='round,pad=0.3', facecolor='lightgreen', alpha=0.7))
53
54# Plot 2: Daily Average Abnormal Returns (bar chart)
55colors = ['#2ecc71' if x > 0 else '#e74c3c' for x in avg_daily_ar]
56ax2.bar(range(len(avg_daily_ar)), avg_daily_ar, color=colors, alpha=0.7, edgecolor='black')
57ax2.axhline(0, color='gray', linestyle='-', linewidth=1)
58ax2.set_xlabel('Days Since Announcement', fontsize=12, fontweight='bold')
59ax2.set_ylabel('Average Daily Abnormal Return (%)', fontsize=12, fontweight='bold')
60ax2.set_title('Daily Abnormal Returns\nAverage Return per Day Across All Events', 
61              fontsize=14, fontweight='bold', pad=15)
62ax2.grid(True, alpha=0.3, axis='y')
63
64plt.tight_layout()
65plt.show()
66
67# Summary statistics
68print(f"\nTemporal Analysis:")
69print(f"  Day 0 (Announcement): {day_0_car:.2f}%")
70print(f"  First Week (Days 0-7): {avg_cumulative_ar.iloc[7] if len(avg_cumulative_ar) > 7 else final_car:.2f}%")
71print(f"  Full Window (Days 0-{len(avg_cumulative_ar)-1}): {final_car:.2f}%")
72print(f"  ")
73print(f"  % of total return on Day 0: {(day_0_car/final_car)*100:.1f}%")
74print(f"  % of total return in first week: {(day_5_car/final_car)*100:.1f}%")

png

Temporal Analysis:
  Day 0 (Announcement): 1.60%
  First Week (Days 0-7): 6.73%
  Full Window (Days 0-11): 7.30%
  
  % of total return on Day 0: 21.9%
  % of total return in first week: 65.7%

Figure 2. Average CAR build-up in event time with 95% confidence band. What it shows: The cumulative abnormal return climbs from roughly 1.6% on announcement day to 6.7% within the first trading week and levels out near 7.3% by day 11, meaning ~22% of the move happens instantly while ~66% accrues during the first week.

Implication: Execution speed matters—there is a quick pop to capture—but there is still meaningful drift over several sessions, so strategies can combine prompt entry with staged exits rather than forcing a same-day trade.

1# Visualization: Event-time Heatmap by CAR Decile
2try:
3    import numpy as np
4    import pandas as pd
5    import seaborn as sns
6    import matplotlib.pyplot as plt
7
8    # Build daily_returns_df if missing
9    if 'daily_returns_df' not in globals():
10...
1# Visualization: Event-time Heatmap by CAR Decile
2try:
3    import numpy as np
4    import pandas as pd
5    import seaborn as sns
6    import matplotlib.pyplot as plt
7
8    # Build daily_returns_df if missing
9    if 'daily_returns_df' not in globals():
10        max_days = max(len(x) for x in df['daily_abnormal_returns'])
11        matrix = []
12        for returns in df['daily_abnormal_returns']:
13            matrix.append(returns + [np.nan] * (max_days - len(returns)))
14        daily_returns_df = pd.DataFrame(matrix)
15
16    car_series = df['car'].reset_index(drop=True)
17
18    try:
19        deciles = pd.qcut(car_series, 10, labels=[f'D{i}' for i in range(1, 11)])
20    except ValueError:
21        bins = min(5, len(car_series.unique()))
22        deciles = pd.cut(car_series, bins=bins, labels=[f'G{i}' for i in range(1, bins + 1)])
23
24    grouped = daily_returns_df.copy()
25    grouped['decile'] = deciles
26    heat_decile = grouped.groupby('decile', observed=True).mean(numeric_only=True).reindex(sorted(grouped['decile'].dropna().unique()))
27
28    plt.figure(figsize=(14, 6))
29    ax = sns.heatmap(heat_decile, cmap='RdBu_r', center=0, cbar_kws={'label': 'Abnormal return (%)'})
30    ax.set_title('Abnormal Returns by CAR Decile', fontsize=14, fontweight='bold', pad=12)
31    ax.set_xlabel('Days since approval')
32    ax.set_ylabel('CAR decile (low → high)')
33    plt.tight_layout()
34    plt.show()
35
36except Exception as e:
37    print(f'Heatmap could not be generated: {e}')

png

Figure 3. Heatmap of abnormal returns by CAR decile across the event window. What it shows: Rows aggregate daily abnormal returns for groups of events ranked by total CAR, letting you see how quickly the strongest vs. weakest approvals move in event time.

Implication: High-CAR deciles glow positive almost immediately and stay elevated, while lower deciles fade or slip negative—so the dispersion in outcomes is tied to how early the move materialises.

The chart reveals an important pattern: most of the price movement happens in the first few days. Day 0 alone contributes roughly one-fifth of the 15-day CAR, and about two-thirds of the total buildup arrives by the end of the first trading week. That points to a market that reacts quickly but still drifts as additional information is digested.

1# Visualization 2: Top and Bottom Performers
2fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 7))
3
4# Top 10 performers
5top_10 = df.nlargest(10, 'car')
6colors_top = ['#27ae60' if x > 20 else '#2ecc71' for x in top_10['car']]
7bars1 = ax1.barh(range(len(top_10)), top_10['car'], color=colors_top, edgecolor='black', linewidth=1)
8ax1.set_yticks(range(len(top_10)))
9ax1.set_yticklabels([f"{row['ticker']}" for _, row in top_10.iterrows()], fontsize=11)
10...
1# Visualization 2: Top and Bottom Performers
2fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 7))
3
4# Top 10 performers
5top_10 = df.nlargest(10, 'car')
6colors_top = ['#27ae60' if x > 20 else '#2ecc71' for x in top_10['car']]
7bars1 = ax1.barh(range(len(top_10)), top_10['car'], color=colors_top, edgecolor='black', linewidth=1)
8ax1.set_yticks(range(len(top_10)))
9ax1.set_yticklabels([f"{row['ticker']}" for _, row in top_10.iterrows()], fontsize=11)
10ax1.set_xlabel('CAR (%)', fontsize=12, fontweight='bold')
11ax1.set_title('Top 10 Performers', fontsize=13, fontweight='bold', pad=15)
12ax1.grid(axis='x', alpha=0.3)
13ax1.axvline(0, color='black', linewidth=0.8)
14
15# Directional x-axis limit (positive side only)
16top_max = max(0.0, float(top_10['car'].max()))
17pos_margin = max(3.0, 0.05 * top_max) if top_max > 0 else 3.0
18ax1.set_xlim(0, top_max + pos_margin)
19
20for i, (bar, val) in enumerate(zip(bars1, top_10['car'])):
21    label_x = min(val + 0.02 * (top_max + pos_margin), top_max + pos_margin - 0.5)
22    ax1.text(label_x, i, f'{val:.1f}%', va='center', ha='left', fontsize=10, fontweight='bold')
23
24# Bottom 10 performers
25bottom_10 = df.nsmallest(10, 'car')
26colors_bottom = ['#c0392b' if x < -20 else '#e74c3c' for x in bottom_10['car']]
27bars2 = ax2.barh(range(len(bottom_10)), bottom_10['car'], color=colors_bottom, edgecolor='black', linewidth=1)
28ax2.set_yticks(range(len(bottom_10)))
29ax2.set_yticklabels([f"{row['ticker']}" for _, row in bottom_10.iterrows()], fontsize=11)
30ax2.set_xlabel('CAR (%)', fontsize=12, fontweight='bold')
31ax2.set_title('Bottom 10 Performers', fontsize=13, fontweight='bold', pad=15)
32ax2.grid(axis='x', alpha=0.3)
33ax2.axvline(0, color='black', linewidth=0.8)
34
35# Directional x-axis limit (negative side only)
36bottom_min = min(0.0, float(bottom_10['car'].min()))
37neg_margin = max(3.0, 0.05 * abs(bottom_min)) if bottom_min < 0 else 3.0
38ax2.set_xlim(bottom_min - neg_margin, 0)
39
40for i, (bar, val) in enumerate(zip(bars2, bottom_10['car'])):
41    label_x = max(val - 0.02 * (abs(bottom_min) + neg_margin), bottom_min - neg_margin + 0.5)
42    ax2.text(label_x, i, f'{val:.1f}%', va='center', ha='right', fontsize=10, fontweight='bold')
43
44plt.suptitle('Extreme Reactions: Best and Worst Performers After FDA Approval',
45            fontsize=15, fontweight='bold', y=1.00)
46plt.tight_layout()
47plt.show()
48
49summary = pd.DataFrame([
50    ("Best", f"{df.loc[df['car'].idxmax(), 'ticker']} with {df['car'].max():.2f}% CAR"),
51    ("Worst", f"{df.loc[df['car'].idxmin(), 'ticker']} with {df['car'].min():.2f}% CAR"),
52    ("Range", f"{df['car'].max() - df['car'].min():.2f} percentage points")
53])
54Markdown(f"**Extreme Performers** \n {summary.to_markdown(index=False, headers=[''])}")

png

Extreme Performers

BestURGN with 81.66% CAR
WorstTNXP with -75.76% CAR
Range157.43 percentage points

Figure 4. Top and bottom 10 approval reactions ranked by CAR. What it shows: The bar charts spotlight the 10 biggest winners and losers in the sample, emphasizing how outlier approvals can deliver +40% or -40% abnormal moves in the same 15-day window.

Implication: Averages mask the tail risk—concentrated bets around a single approval can pay off massively or implode, so strategy design needs diversification or strict risk controls.

Finding 3: Trading Volume Spikes, But Doesn't Predict Direction

I also examined whether unusual trading volume could signal the magnitude or direction of price movements:

1# Visualization 3: CAR vs Volume Ratio
2fig, ax = plt.subplots(figsize=(14, 8))
3
4# Create scatter plot
5scatter = ax.scatter(df['volume_ratio'], df['car'],
6                    c=df['car'], cmap='RdYlGn', vmin=-50, vmax=50,
7                    s=120, alpha=0.7, edgecolors='black', linewidth=1)
8
9# Add colorbar
10...
1# Visualization 3: CAR vs Volume Ratio
2fig, ax = plt.subplots(figsize=(14, 8))
3
4# Create scatter plot
5scatter = ax.scatter(df['volume_ratio'], df['car'],
6                    c=df['car'], cmap='RdYlGn', vmin=-50, vmax=50,
7                    s=120, alpha=0.7, edgecolors='black', linewidth=1)
8
9# Add colorbar
10cbar = plt.colorbar(scatter, ax=ax)
11cbar.set_label('CAR (%)', rotation=270, labelpad=25, fontsize=12, fontweight='bold')
12
13# Add reference lines
14ax.axhline(0, color='gray', linestyle='--', alpha=0.6, linewidth=1.5, label='Zero CAR')
15ax.axvline(1, color='blue', linestyle='--', alpha=0.6, linewidth=1.5, label='Normal Volume')
16
17# Highlight extreme cases
18top_3_car = df.nlargest(3, 'car')
19for _, row in top_3_car.iterrows():
20    ax.annotate(row['ticker'], 
21               xy=(row['volume_ratio'], row['car']),
22               xytext=(15, 15), textcoords='offset points',
23               bbox=dict(boxstyle='round,pad=0.4', facecolor='yellow', alpha=0.8, edgecolor='black'),
24               arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0.3', lw=1.5),
25               fontsize=10, fontweight='bold')
26
27# Calculate correlation
28valid_data = df[['volume_ratio', 'car']].dropna()
29correlation = valid_data.corr().iloc[0, 1]
30
31# Labels and formatting
32ax.set_xlabel('Volume Ratio (Event Volume / Baseline Volume)', fontsize=13, fontweight='bold')
33ax.set_ylabel('Cumulative Abnormal Return (%)', fontsize=13, fontweight='bold')
34ax.set_title('Price Movement vs Trading Volume Response\nHigher volume does not necessarily predict larger price moves', 
35            fontsize=14, fontweight='bold', pad=20)
36ax.grid(True, alpha=0.3)
37ax.legend(fontsize=11, loc='upper right')
38
39# Set reasonable axis limits
40ax.set_xlim(-0.5, min(df['volume_ratio'].max() + 1, 10))
41
42# Add correlation annotation
43textstr = f'Correlation: {correlation:.3f}\n(weak relationship)'
44props = dict(boxstyle='round', facecolor='lightblue', alpha=0.85, edgecolor='black', linewidth=1.5)
45ax.text(0.05, 0.97, textstr, transform=ax.transAxes, fontsize=12,
46        verticalalignment='top', bbox=props, fontweight='bold')
47
48plt.tight_layout()
49plt.show()
50
51results = pd.DataFrame([
52    ("Mean volume ratio", f"{df['volume_ratio'].mean():.2f}x baseline"),
53    ("Median volume ratio", f"{df['volume_ratio'].median():.2f}x baseline"),
54    ("Correlation with CAR", f"{correlation:.3f}")
55])
56
57summary = f"""
58**Volume Analysis** \n
59{results.to_markdown(index=False, headers=[''])}
60
61Interpretation: The weak correlation ({correlation:.3f}) suggests that
62trading volume alone is not a reliable predictor of price direction or magnitude.
63"""
64
65Markdown(summary)

png

Volume Analysis

Mean volume ratio1.56x baseline
Median volume ratio1.00x baseline
Correlation with CAR0.498

Interpretation: The weak correlation (0.498) suggests that trading volume alone is not a reliable predictor of price direction or magnitude.

Figure 5. Scatter of CAR versus volume spike on announcement day. What it shows: Each point compares an event's CAR with its volume spike on announcement day; the weak correlation confirms that heavy trading interest does not map neatly to positive or negative abnormal returns.

Implication: Volume is a signal that the market is paying attention, but you cannot lean on it alone for direction—combine it with fundamentals, clinical context, or positioning data before trading.

What This Means for Investors

Three key takeaways from this analysis:

1. There's a measurable "approval premium"
On average, stocks gain 6-7% in abnormal returns following FDA approvals. This isn't random noise—it's a statistically significant pattern. However, it's an average across diverse situations, not a guarantee for any individual stock.

2. Context determines magnitude
The variance is enormous: from -76% to +82%. Company size, market expectations, therapeutic significance, and competitive landscape all matter. A first-in-class drug approval for a small biotech creates a fundamentally different dynamic than a line extension for big pharma.

3. Volume ≠ Direction
High trading volume signals market attention, but it doesn't tell you whether prices will rise or fall. Some approvals trigger massive volume with minimal price impact, while others see significant moves on modest volume.

Methodology

This analysis examined FDA approval announcements from April-October 2025:

  • Data source: BioAPI aggregates approval news from Drugs.com
  • Price data: Historical stock prices from Yahoo Finance (adjusted close where available)
  • Baseline window: 120 trading days before announcement (establishes normal volatility)
  • Event window: 15 trading days after announcement
  • Buffer: 10 trading days between estimation and event windows
  • Exclusions: Events less than 15 trading days old are excluded to ensure a full post-event window
  • CAR calculation: Sum of (actual daily returns - baseline average) over event window
  • Sample: Events with complete price data

The CAR methodology here uses a constant-mean model: abnormal return is the stock's actual daily return minus its pre-event average daily return. This approach approximates event-driven effects but does not explicitly control for market-wide moves (e.g., via a market model).

All code for this analysis is reproducible—run this notebook yourself to verify the results or extend the analysis.

Limitations

  • Constant-mean model does not explicitly control for market moves; a market-model (with alpha/beta) may refine estimates.
  • Ticker parsing from headlines/descriptions can introduce noise or miss multi-ticker events.
  • Short 15-day post window may miss longer-run drift or reversal.
  • Results reflect available events with complete data; very recent approvals are excluded to ensure a full window.
  • Event clustering (multiple approvals around the same dates) can affect independence assumptions.