When a company changes its name, should you sell?
On 4 September 1991, during congressional testimony, Warren Buffett famously said: “Lose money for the firm, and I will be understanding. Lose a shred of reputation for the firm, and I will be ruthless.” Because a company’s name is tied to its reputation, does changing that name convey a trading signal?
We have had several corporate name changes, such as Apple Computer, Inc. becoming Apple Inc. in 2007. AirBed & Breakfast, Inc. became Airbnb, Inc. in 2010. Although these may not seem like big changes, we have had other questionable ones, such as Philip Morris Companies becoming Altria in 2003. Smith and Malone (2003), reading Philip Morris’s internal documents, find the new name was intended in part to distance the parent company from tobacco’s reputation.
In this post, I examine how US stocks perform relative to the S&P 500 12 months after their renaming. Firms that renamed had a median adjusted return 5.1 percentage points lower than comparable firms, and those that were unprofitable before the renaming did worse. For profitable firms, I find little evidence that renaming predicts subsequent performance. For unprofitable firms, renaming precedes 13 percentage points of underperformance against firms of the same size and industry that were also losing money.
Starting point
The analysis runs off five inputs. Building them needs an LSEG licence, and because the data is licensed I cannot share the raw files. Every step that turns them into a table is below, so a reader with the same data can copy the code and get the same numbers.
- The daily panel on disk, one row per stock-day:
Instrument,Date,Daily Total Return,Company Market Cap,Deal Event Announcement Date,Deal Event Type. It is read once, in chunks. f, one row per firm-year of accounts:Instrument,announce_date,net_income,assets. The announcement date is what makes the profitability label point-in-time.spx, monthly levels of the S&P 500 total-return index.SPXTR, with columnsymandlevel. The stock returns are total returns, so the benchmark has to be one too, and.SPXis price-only.static, one row per firm:Instrumentandindustry, the TRBC industry group name.ric_map, one row per ticker:InstrumentandOrgPermID. This is the join key that survives a name change.
Throughout, ym is a monthly period and every return is a decimal.
import re
import difflib
import numpy as np
import pandas as pd
import statsmodels.api as sm
import matplotlib.pyplot as plt
from scipy import stats
PANEL = r'US_stocks.csv' # the daily panel, 27.4M rows
HORIZON = 12 # months on each side of the event
BUFFER = 24 # months a future renamer stays out of the control pool
N_MATCH = 5 # controls per renamer
CALIPER = 0.60 # maximum standardised distance to a control
RED, BLUE, GREY = '#C44E52', '#1F77B4', '#CCCCCC'Where the names live
LSEG does not expose historical names through the usual TR.* fields. TR.CompanyName returns today’s name even when given a past date. The name history sits in the search index instead, in the ORGANISATIONS view, under a property called PreviousNames.
import lseg.data as ld
ld.get_config().set_param('http.request-timeout', 300) # raise this BEFORE open_session
ld.open_session()
df = ld.discovery.search(
view=ld.discovery.Views.ORGANISATIONS,
filter="OAPermID in ('4295905573')", # space-separated inside in(...)
select='CommonName,LegalName,PreviousNames,OAPermID,OrganisationStatus',
)Two points of syntax cost me an afternoon. The select string has to be comma-separated, and a semicolon returns zero rows with no error at all. The values inside in (...) have to be space-separated, and a comma raises Invalid filter: found COMMA in IN_CLAUSE.
The join key is the organisation PermID, which survives name changes, ticker changes and delisting. The full pull walks the 10,078 tickers in my US panel in batches of 100, maps each one to its PermID with TR.OrganizationID, then asks the search index for that organisation’s history. Batching those calls gives one frame, org, with a row per organisation.
PreviousNames comes back as a flat list of [old name, valid from, valid to], newest first, with consecutive records glued together by a tilde. Apple returns ['Apple Computer, Inc.', '1977-01-03', '2007-09-01'].
def parse_previous_names(cell):
"""One PreviousNames cell into a list of (old_name, valid_from, valid_to)."""
if not isinstance(cell, (list, np.ndarray)) or len(cell) == 0:
return []
SEP = '\x00' # NUL never appears in a company name
flat = SEP.join(str(x) for x in cell) # rebuild the raw string the API sent
out = []
for record in flat.split('~'): # each ~ separates one historical name
parts = record.split(SEP)
if len(parts) >= 3 and parts[0].strip():
out.append((parts[0].strip(), parts[1].strip(), parts[2].strip()))
return out
rows = []
for _, row in org.iterrows():
records = parse_previous_names(row.get('PreviousNames'))
current = row.get('LegalName') or row.get('CommonName')
for i, (old_name, vfrom, vto) in enumerate(records):
# records arrive newest first, so the name that REPLACED record i is record i-1
rows.append({'OrgPermID': row.get('OAPermID'), 'OldName': old_name,
'NewName': current if i == 0 else records[i - 1][0],
'ValidFrom': vfrom, 'RenameDate': vto, # vto is the event date
'Status': row.get('OrganisationStatus'),
'Country': row.get('CountryHeadquartersName')})
ev = pd.DataFrame(rows)
ev['RenameDate'] = pd.to_datetime(ev['RenameDate'], errors='coerce')
print(f'{len(ev):,} rename records from {ev["OrgPermID"].nunique():,} companies')8,985 rename records from 5,369 companies
OrganisationStatus comes back on the same call, and it is worth keeping. The search index holds delisted organisations as well as live ones, and firms that rename while distressed are the ones most likely to delist.
Step 1. Build the monthly panel and the outcome
The outcome throughout is the 12-month S&P 500-adjusted total return: the firm’s total return over months t+1 to t+12, minus the S&P 500 total return over the identical months, requiring all twelve.
I compound the daily panel into months first, in one pass that also stores the deal events used by the filter in Step 2. Log returns are additive, so a month split across two chunks of a 2.7 GB file can be summed back together at the end.
USE = ['Instrument', 'Date', 'Daily Total Return', 'Company Market Cap',
'Deal Event Announcement Date', 'Deal Event Type']
monthly_parts, mcap_parts, deal_parts = [], [], []
for chunk in pd.read_csv(PANEL, chunksize=1_000_000, usecols=USE, low_memory=False):
chunk['Date'] = pd.to_datetime(chunk['Date'], errors='coerce')
chunk = chunk.dropna(subset=['Date', 'Instrument'])
chunk['ym'] = chunk['Date'].dt.to_period('M')
dl = chunk.dropna(subset=['Deal Event Announcement Date'])
if len(dl):
deal_parts.append(dl[['Instrument', 'Deal Event Announcement Date',
'Deal Event Type']].drop_duplicates())
r = pd.to_numeric(chunk['Daily Total Return'], errors='coerce') / 100.0 # panel is in percent
r = r.clip(-1.0, 1.0) # the raw file reaches +89,900% in a day on corrupted rows
chunk['logret'] = np.log1p(r)
monthly_parts.append(chunk.dropna(subset=['logret'])
.groupby(['Instrument', 'ym'], observed=True)['logret'].sum())
mc = chunk.dropna(subset=['Company Market Cap']).sort_values('Date')
mcap_parts.append(mc.groupby(['Instrument', 'ym'], observed=True)
.last()[['Company Market Cap', 'Date']])
monthly = pd.concat(monthly_parts).groupby(level=[0, 1]).sum() # repair months split across chunks
mcap = (pd.concat(mcap_parts).sort_values('Date')
.groupby(level=[0, 1]).last()['Company Market Cap'])
m = np.expm1(monthly).rename('ret').to_frame().join(mcap).reset_index()
print(f'{len(m):,} firm-months across {m["Instrument"].nunique():,} firms')1,348,652 firm-months across 10,064 firms
Then I put every firm on a continuous month grid, so a gap in trading becomes a missing month rather than a silently shortened window, and roll the twelve months forward and backward.
full = (m.set_index(['Instrument', 'ym']).groupby(level=0, observed=True)
.apply(lambda x: x.droplevel(0).reindex(
pd.period_range(x.index.get_level_values(1).min(),
x.index.get_level_values(1).max(), freq='M'))))
full.index.names = ['Instrument', 'ym']
full = full.reset_index()
spx['spx_lr'] = np.log1p(spx['level'].pct_change()) # the benchmark, in log returns
full = full.merge(spx[['ym', 'spx_lr']], on='ym', how='left')
full = full.merge(static[['Instrument', 'industry']], on='Instrument', how='left')
full['lr'] = np.log1p(full['ret'])
rev = full.iloc[::-1].copy() # reversed, so a forward roll looks back
rev['a'] = rev.groupby('Instrument', observed=True)['lr'].shift(1) # start at t+1, skip the event month
rev['b'] = rev.groupby('Instrument', observed=True)['spx_lr'].shift(1)
for src, dst in (('a', 'fs'), ('b', 'fx')):
rev[dst] = (rev.groupby('Instrument', observed=True)[src]
.rolling(HORIZON, min_periods=HORIZON).sum().reset_index(level=0, drop=True))
full = rev.iloc[::-1].copy()
full['c'] = full.groupby('Instrument', observed=True)['lr'].shift(1) # the trailing twelve months
full['e'] = full.groupby('Instrument', observed=True)['spx_lr'].shift(1)
for src, dst in (('c', 'bs'), ('e', 'bx')):
full[dst] = (full.groupby('Instrument', observed=True)[src]
.rolling(HORIZON, min_periods=HORIZON).sum().reset_index(level=0, drop=True))
full['ER_post12'] = np.expm1(full['fs']) - np.expm1(full['fx'])
full['ER_pre12'] = np.expm1(full['bs']) - np.expm1(full['bx'])
mc_lag = full.groupby('Instrument', observed=True)['Company Market Cap'].shift(1)
full['log_mcap'] = np.log(mc_lag.where(mc_lag > 0)) # PREVIOUS month end
print(f'panel rows {len(full):,}')panel rows 1,350,926
The min_periods=HORIZON is what enforces the strict window: eleven months of data gives a missing value, never a shorter window labelled as a year.
The shift(1) on market capitalisation matters more than it looks. The panel stores the value at month end, so for a firm renaming mid-month the same month’s figure already contains the event. Using it as a matching variable let the outcome leak into the covariate, and the correlation between market-cap growth and the same month’s return was 0.94.
Step 2. Filter the events
A large share of the 8,985 records are not economic events. I strip the legal-wrapper words from both names, compare what is left, and drop the pairs that match.
LEGAL_WORDS = {'INC', 'INCORPORATED', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LLC', 'LP',
'LTD', 'LIMITED', 'PLC', 'SA', 'AG', 'NV', 'HOLDING', 'HOLDINGS', 'GROUP', 'THE'}
def normalise(name):
s = re.sub(r'[^A-Z0-9 ]+', ' ', str(name).upper()) # case is not a rename
return re.sub(r'\s+', ' ', s).strip()
def core(name):
"""The business identity, with the legal wrapper removed."""
return ' '.join(t for t in normalise(name).split() if t not in LEGAL_WORDS)
ev['OldCore'], ev['NewCore'] = ev['OldName'].apply(core), ev['NewName'].apply(core)
# identical after normalising, so only capitalisation or punctuation moved
ev['CosmeticOnly'] = [normalise(o) == normalise(n)
for o, n in zip(ev['OldName'], ev['NewName'])]
# same identity, different wrapper: "X Corp" becomes "X Inc"
ev['LegalFormOnly'] = [(oc == nc) and (normalise(o) != normalise(n)) for oc, nc, o, n
in zip(ev['OldCore'], ev['NewCore'], ev['OldName'], ev['NewName'])]
ev['UseInStudy'] = ~ev['CosmeticOnly'] & ~ev['LegalFormOnly']
# how far the identity moved, used to pick one event when a firm renames twice in a month
ev['NameDistance'] = [1 - difflib.SequenceMatcher(None, a, b).ratio()
for a, b in zip(ev['OldCore'], ev['NewCore'])]
# attach the panel ticker and mark the events the price panel can bracket
ev = ev.merge(ric_map[['Instrument', 'OrgPermID']], on='OrgPermID', how='left')
ev = ev.rename(columns={'Instrument': 'PanelRIC'})
ev['InPanelWindow'] = ev['RenameDate'].between('2000-01-01', '2026-02-27')Then I drop the companies that are not operating firms, and the renames that a deal explains. Company A buys Company B, doubles its revenue, renames, and the stock rises. Attributing that to the name would be wrong, so any rename within twelve months of a deal event goes.
deals = pd.concat(deal_parts).drop_duplicates()
deals['DealDate'] = pd.to_datetime(deals['Deal Event Announcement Date'], errors='coerce')
deals = deals.dropna(subset=['DealDate'])
SPAC_PAT = (r'\b(?:ACQUISITION|BLANK CHECK)\b'
r'|\b(?:CAPITAL|HOLDINGS?)\s+(?:CORP|CORPORATION|CO)\b\s*'
r'(?:I{1,3}|IV|V|VI{1,3}|IX|X{1,3}|\d+)?\s*$') # sponsors run numbered series
FUND_PAT = r'\b(?:FUND|TRUST|ETF|PORTFOLIO|INDEX|REIT)\b'
OLD, NEW = ev['OldName'].fillna('').str.upper(), ev['NewName'].fillna('').str.upper()
ev['IsSPAC'] = OLD.str.contains(SPAC_PAT, regex=True, na=False) # old name only, the new one is the target
ev['IsFund'] = OLD.str.contains(FUND_PAT, na=False) | NEW.str.contains(FUND_PAT, na=False)
ev['IsForeign'] = ev['Country'].notna() & (ev['Country'] != 'United States')
ev['PureRename'] = (ev['UseInStudy'] & ev['InPanelWindow']
& ~ev['IsSPAC'] & ~ev['IsFund'] & ~ev['IsForeign'])
deal_lookup = deals.groupby('Instrument')['DealDate'].apply(list).to_dict()
def near_deal(ric, when, months=HORIZON):
if pd.isna(when) or ric not in deal_lookup:
return False
lo, hi = when - pd.DateOffset(months=months), when + pd.DateOffset(months=months)
return any(lo <= d <= hi for d in deal_lookup[ric])
ev['NearDeal'] = [near_deal(r, d) for r, d in zip(ev['PanelRIC'], ev['RenameDate'])]
ev['PureRenameNoDeal'] = ev['PureRename'] & ~ev['NearDeal']Every exclusion is counted in the order it is applied, and the last one needs the monthly panel from Step 1.
funnel = []
def step(label, n):
funnel.append({'restriction': label, 'events': n})
step('Raw name records, joined to panel tickers', len(ev))
step('Not cosmetic, not legal-form only', int(ev['UseInStudy'].sum()))
step('Rename date inside the 2000-2026 panel', int((ev['UseInStudy'] & ev['InPanelWindow']).sum()))
step('Not a SPAC, fund or foreign domicile', int(ev['PureRename'].sum()))
step('No deal event within twelve months', int(ev['PureRenameNoDeal'].sum()))
t_ev = ev[ev['PureRenameNoDeal'] & ev['PanelRIC'].notna()].copy()
t_ev['ym'] = t_ev['RenameDate'].dt.to_period('M')
t_ev = (t_ev.sort_values('NameDistance', ascending=False)
.drop_duplicates(['PanelRIC', 'ym']) # keep the largest change that month
.rename(columns={'PanelRIC': 'Instrument'}).sort_values('RenameDate'))
step('One event per firm-month', len(t_ev))
traded = set(zip(m['Instrument'], m['ym'])) # the panel built in Step 1
keep = np.array([(i, y) in traded for i, y in zip(t_ev['Instrument'], t_ev['ym'])])
dropped, t_ev = t_ev[~keep], t_ev[keep]
step('Trading in the rename month', len(t_ev))
fn = pd.DataFrame(funnel)
fn['lost'] = (fn['events'].shift(1) - fn['events']).astype('Int64')
print(fn.to_string(index=False))| Restriction | Events | Lost |
|---|---|---|
| Raw name records, joined to panel tickers | 8,990 | |
| Not cosmetic, not legal-form only | 7,990 | 1,000 |
| Rename date inside the 2000-2026 panel | 5,690 | 2,300 |
| Not a SPAC, fund or foreign domicile | 4,293 | 1,397 |
| No deal event within twelve months | 4,177 | 116 |
| One event per firm-month | 4,096 | 81 |
| Trading in the rename month | 1,955 | 2,141 |
The first row is larger than the 8,985 records parsed, because one organisation can carry several tickers in the panel and the join gives each of them a row.
The last row is the largest cut, so it is worth asking where those 2,141 events went. I place each one against the firm’s own span in the panel, and I count how many of the survivors belong to companies that have since delisted.
span = m.groupby('Instrument')['ym'].agg(['min', 'max']) # each firm's life in the panel
where = ['before the firm appears' if y < span.loc[i, 'min'] else
'after the firm leaves' if y > span.loc[i, 'max'] else 'inside the span'
for i, y in zip(dropped['Instrument'], dropped['ym']) if i in span.index]
print(pd.Series(where).value_counts().to_dict())
win = ev['UseInStudy'] & ev['InPanelWindow']
print(f'delisted organisations inside the panel window: '
f'{int((win & (ev["Status"] == "Delisted")).sum()):,} of {int(win.sum()):,}'){'before the firm appears': 1443, 'after the firm leaves': 693, 'inside the span': 5}
delisted organisations inside the panel window: 2,948 of 5,690
So 1,443 of the dropped events happen before the company enters the panel and 693 after it leaves, and there is no stock to measure either way. Only 5 fall inside a firm’s span, which is what I want to see.
The second line is the reason for using the search index at all. 2,948 of the 5,690 in-window records belong to organisations marked delisted, so a sample built from tickers that trade today would miss over half the firms this question is about.
The deal filter is the row to watch. It flagged 116 events, which is a low count for a sample this size, and Wu (2010) reads press releases by hand to do the same job.
Step 3. Label profitability at the rename date
I classify a firm as unprofitable when net income in the latest annual statement publicly announced before the rename date is negative, and profitable otherwise. The announcement date is what makes the label available in real time, because accounts for a year ending in December are typically published the following spring.
f['announce_date'] = pd.to_datetime(f['announce_date'], errors='coerce',
utc=True).dt.tz_localize(None)
f = f.dropna(subset=['announce_date', 'Instrument']).sort_values(['Instrument', 'announce_date'])
f['roa_pit'] = f['net_income'] / f['assets'].where(f['assets'] > 0)
PIT = {i: (g['announce_date'].values, g['net_income'].values, g['roa_pit'].values)
for i, g in f.groupby('Instrument', observed=True)}
def pit(inst, asof):
"""The newest annual statement announced strictly BEFORE asof."""
rec = PIT.get(inst)
if rec is None:
return np.nan, np.nan
dates, ni, roa = rec
i = np.searchsorted(dates, np.datetime64(asof), side='left') - 1
return (np.nan, np.nan) if i < 0 else (ni[i], roa[i])
t_ev['ni_pit'], t_ev['roa_pit'] = zip(*[pit(i, dt) for i, dt in
zip(t_ev['Instrument'], t_ev['RenameDate'])])
t_ev['Unprof'] = np.where(pd.notna(t_ev['ni_pit']),
(t_ev['ni_pit'] < 0).astype(float), np.nan)
# the exact rename date, kept for the matching step
rename_date = {(i, y): dt for i, y, dt in
zip(t_ev['Instrument'], t_ev['ym'], t_ev['RenameDate'])}
age = [(dt - PIT[i][0][np.searchsorted(PIT[i][0], np.datetime64(dt), 'left') - 1]).days
for i, dt in zip(t_ev['Instrument'], t_ev['RenameDate']) if i in PIT]
print(f'median age of the report used: {np.median(age):.0f} days')median age of the report used: 147 days
So this is a label an investor could have read on the day.
Step 4. Compare renamers with every eligible control
I compare every renamer with every eligible control over the same twelve months, without conditioning on anything else. Controls are firms that never rename, plus observations from eventual renamers more than 24 months before their first eligible rename. A firm is never used as a control once it has renamed.
key = {k: i for i, k in enumerate(zip(full['Instrument'], full['ym']))}
full['Rename'] = 0
hits = [key.get((i, y)) for i, y in zip(t_ev['Instrument'], t_ev['ym'])]
full.loc[[h for h in hits if h is not None], 'Rename'] = 1
first_ren = t_ev.groupby('Instrument')['ym'].min().to_dict()
def elig(inst, ym):
fr = first_ren.get(inst)
return True if fr is None else (fr - ym).n > BUFFER
full['ctrl_eligible'] = (full['Rename'] == 0) & np.array(
[elig(i, y) for i, y in zip(full['Instrument'], full['ym'])])
sel = ((full['Rename'] == 1) | full['ctrl_eligible']) & full['ER_post12'].notna()
d = full[sel][['Instrument', 'ym', 'Rename', 'ER_post12', 'ER_pre12',
'log_mcap', 'industry']].copy()
lab = {(i, y): (u, r) for i, y, u, r in
zip(t_ev['Instrument'], t_ev['ym'], t_ev['Unprof'], t_ev['roa_pit'])}
d['Unprof'] = [lab.get((i, y), (np.nan, np.nan))[0] if rn == 1 else np.nan
for i, y, rn in zip(d['Instrument'], d['ym'], d['Rename'])]
d['roa_m'] = [lab.get((i, y), (np.nan, np.nan))[1] if rn == 1 else np.nan
for i, y, rn in zip(d['Instrument'], d['ym'], d['Rename'])]
treated = d[(d['Rename'] == 1) & d['Unprof'].notna()].copy()
treated['Unprof'] = treated['Unprof'].astype(int)
controls = d[d['Rename'] == 0].copy()
print(f'treated {len(treated):,} events, {treated["Instrument"].nunique():,} firms, '
f'{int((treated["Unprof"] == 0).sum()):,} profitable and '
f'{int((treated["Unprof"] == 1).sum()):,} not')
print(f'window {treated["ym"].min()} to {treated["ym"].max()}')treated 1,571 events, 1,346 firms, 926 profitable and 645 not
window 2000-02 to 2025-02
The events run from February 2000 to February 2025, because the price panel ends in February 2026 and the last rename that can carry a full year is February 2025.
def two_col(t_, c_):
"""Table 1 Panel A: renamers, controls, and the difference computed unrounded."""
r, n = t_['ER_post12'], c_['ER_post12']
print(f'{"Observations":<26}{len(r):>12,}{len(n):>14,}')
print(f'{"Firms":<26}{t_["Instrument"].nunique():>12,}{c_["Instrument"].nunique():>14,}')
for nm, agg in [('Mean adjusted return', 'mean'), ('Median adjusted return', 'median')]:
a, b = getattr(r, agg)(), getattr(n, agg)()
print(f'{nm:<26}{a*100:>11.1f}%{b*100:>13.1f}%{(a-b)*100:>+9.1f}pp')
a, b = (r < 0).mean(), (n < 0).mean()
print(f'{"Share below the S&P 500":<26}{a*100:>11.1f}%{b*100:>13.1f}%{(a-b)*100:>+9.1f}pp')
two_col(treated, controls)Table 1, Panel A. All firms. 12-month S&P 500-adjusted total return.
| Renamers | Eligible controls | Difference | |
|---|---|---|---|
| Observations | 1,571 | 971,060 | |
| Firms | 1,346 | 8,099 | |
| Mean adjusted return | 0.8% | 4.5% | -3.6pp |
| Median adjusted return | -7.6% | -2.5% | -5.1pp |
| Share below the S&P 500 | 56.7% | 53.8% | +2.9pp |
Renamers came out 5.1 percentage points below the controls at the median. On its own I would not write about it.
The table carries no test statistics on purpose. The 1,571 renamers sit against roughly a million control firm-months whose twelve-month windows overlap heavily, so a two-sample t-test would treat those controls as independent when they are not.
Mean and median also disagree here. The mean gap is 3.6 points and the median gap is 5.1, because the twelve-month distribution is strongly right-skewed, so I read the median as the primary number throughout.
Step 5. Split on profitability
I then split the same events on the income statement, into firms that were making money before the rename and firms that were not. Panel B needs a label on both sides, so the controls are classified at month start.
allrows = pd.concat([treated, controls], ignore_index=True)
ms = [pit(i, y.to_timestamp()) for i, y in zip(allrows['Instrument'], allrows['ym'])]
allrows['Unprof_ms'] = [np.nan if pd.isna(v[0]) else float(v[0] < 0) for v in ms]
sym = allrows.dropna(subset=['Unprof_ms']).copy()
ctl_lab = sym[sym['Rename'] == 0]
prof = (treated[treated['Unprof'] == 0], ctl_lab[ctl_lab['Unprof_ms'] == 0])
unpr = (treated[treated['Unprof'] == 1], ctl_lab[ctl_lab['Unprof_ms'] == 1])
def cells(t_, c_, kind):
r, n = t_['ER_post12'], c_['ER_post12']
if kind == 'n':
return f'{len(r):>9,}{len(n):>12,}{"":>10}'
a, b = ((r < 0).mean(), (n < 0).mean()) if kind == 'below' \
else (getattr(r, kind)(), getattr(n, kind)())
return f'{a*100:>8.1f}%{b*100:>11.1f}%{(a-b)*100:>+9.1f}pp'
print(f'controls carrying a month-start label: {len(ctl_lab):,}')
for nm, kind in [('Observations', 'n'), ('Mean', 'mean'),
('Median', 'median'), ('Share below', 'below')]:
print(f'{nm:<14}{cells(*prof, kind)} |{cells(*unpr, kind)}')
tu, cu = unpr[0]['ER_post12'], unpr[1]['ER_post12']
print(f'unprofitable renamers above +100%: {(tu > 1.0).mean()*100:.1f}%, max {tu.max()*100:+.0f}%')Table 1, Panel B. By profitability before the rename. 787,268 controls carry a month-start label.
| Prof. renamer | Controls | Diff. | Unprof. renamer | Controls | Diff. | |
|---|---|---|---|---|---|---|
| Observations | 926 | 571,934 | 645 | 215,334 | ||
| Mean adjusted return | 5.2% | 4.2% | +0.9pp | -5.4% | 7.3% | -12.7pp |
| Median adjusted return | -0.4% | -1.2% | +0.8pp | -22.6% | -9.0% | -13.5pp |
| Share below the S&P 500 | 50.1% | 51.8% | -1.7pp | 66.0% | 58.3% | +7.7pp |
The profitable column is a rounding error. The unprofitable column holds all of Panel A. Pooling the two averages a 13-point difference with nothing, which is how the headline becomes 5.1 points.
The skew is worst in exactly this group. Among unprofitable renamers, 7.9% returned more than +100%, with a maximum of +754%, which is why their mean of -5.4% is far less negative than their median of -22.6%. The mean is pulled by that small group of extreme winners, and the median describes the typical firm.
Step 6. Control for what else differs
Of course unprofitable renamers did worse. They were unprofitable. Perhaps they were already more distressed than the firms they are compared with, and the name change is a bystander.
There are two ways to answer that, and I use both. The first is a regression.
Every firm gets its profitability label at month start here, renamers and controls alike. The classification itself therefore cannot be what separates the two groups. Calendar-month fixed effects go in by demeaning within month, and the standard errors are clustered on firm and on calendar month at once.
sym['RxU'] = sym['Rename'] * sym['Unprof_ms']
def twoway(df, yv, xs):
dd = df.dropna(subset=[yv] + xs).copy()
for c in [yv] + xs: # calendar-month fixed effects
dd[c] = dd[c] - dd.groupby('ym', observed=True)[c].transform('mean')
grp = np.column_stack([pd.factorize(dd['Instrument'])[0], pd.factorize(dd['ym'])[0]])
return sm.OLS(dd[yv], sm.add_constant(dd[xs])).fit(
cov_type='cluster', cov_kwds={'groups': grp}), len(dd)
SPECS = {'(1)': ['Rename'],
'(2)': ['Rename', 'Unprof_ms', 'RxU'],
'(3)': ['Rename', 'Unprof_ms', 'RxU', 'ER_pre12', 'log_mcap']}
res = {k: twoway(sym, 'ER_post12', xs) for k, xs in SPECS.items()}
def star(p):
return '***' if p < .01 else ('**' if p < .05 else ('*' if p < .10 else ''))
print(f'{"":<12}' + ''.join(f'{k:>13}' for k in SPECS))
for v in ['Rename', 'Unprof_ms', 'RxU', 'ER_pre12', 'log_mcap']:
line, tline = f'{v:<12}', ' ' * 12
for k in SPECS:
mod, _ = res[k]
if v in mod.params.index:
line += f'{mod.params[v]:+.4f}{star(mod.pvalues[v]):<3}'.rjust(13)
tline += f'({mod.tvalues[v]:.2f})'.rjust(13)
else:
line, tline = line + ' ' * 13, tline + ' ' * 13
print(line)
print(tline)
print(f'{"N":<12}' + ''.join(f'{res[k][1]:>13,}' for k in SPECS))Table 2, Panel A. Regressions. t-statistics in parentheses. Stars mark p below 0.10, 0.05 and 0.01. Coefficients are in decimal-return units, so -0.121 is -12.1 percentage points.
| (1) | (2) | (3) | |
|---|---|---|---|
| Rename | -0.031 | +0.013 | +0.002 |
| (-1.60) | (0.80) | (0.14) | |
| Unprofitable | +0.045*** | +0.040*** | |
| (3.11) | (3.16) | ||
| Rename × Unprofitable | -0.121*** | -0.134*** | |
| (-2.97) | (-3.14) | ||
| Prior 12-month adjusted return | -0.021*** | ||
| (-3.10) | |||
| Log market cap | -0.015*** | ||
| (-5.90) | |||
| Observations | 788,823 | 788,823 | 722,680 |
Column (1) is the pooled effect, and it comes out at -3.1 percentage points with a t-statistic of -1.60. That is Panel A again, and it is the number a study would report if it never split the sample.
Column (2) adds profitability and the interaction. The Rename coefficient moves to +1.3 percentage points at t = 0.80, because in this specification it measures the rename association among profitable firms alone. The interaction takes the whole effect, at -12.1 percentage points.
Column (3) adds prior return and size, which is where the distress story would show up. If unprofitable renamers were smaller firms that were already falling, then controlling for size and prior return should shrink the interaction. It grows, from -12.1 to -13.4 percentage points.
Step 7. Match each renamer to comparable firms
The second answer is matching. I pair each renamer with up to five controls, with replacement. The pairing is exact on calendar month, industry and profitability state, then nearest-neighbour on standardised size, prior return and return on assets inside a caliper of 0.60. Each event contributes one observation: its own return minus the average of its matched controls.
def make_sd(trt_df, pool):
sd = {c: pd.concat([trt_df[c], pool[c]]).std() for c in ('log_mcap', 'ER_pre12')}
sd['roa_m'] = trt_df['roa_m'].std()
return sd
def make_buckets(pool, use_industry=True):
p = pool.dropna(subset=['log_mcap', 'ER_pre12'])
return {k: g for k, g in p.groupby(['ym', 'industry'] if use_industry else 'ym',
observed=True)}
def match(trt_df, buckets, sd, caliper=CALIPER, n_match=N_MATCH, use_industry=True):
"""One row per matched event, plus the covariates needed for the balance table."""
rows, pairs, tcov, ccov, wcov = [], [], [], [], []
for _, r in trt_df.iterrows():
g = buckets.get((r['ym'], r['industry'])) if use_industry else buckets.get(r['ym'])
if g is None or len(g) == 0:
continue
asof = rename_date.get((r['Instrument'], r['ym'])) # the exact rename date
if asof is None:
continue
# every candidate control is classified as of the RENAMING firm's date, not month start
vals = [pit(i, asof) for i in g['Instrument']]
ni = np.array([v[0] for v in vals], dtype=float)
roa = np.array([v[1] for v in vals], dtype=float)
ok = ~np.isnan(ni) & ~np.isnan(roa) & ((ni < 0).astype(float) == r['Unprof'])
if not ok.any():
continue
cand = g[ok]
dist = np.sqrt(((cand['log_mcap'].values - r['log_mcap']) / sd['log_mcap']) ** 2
+ ((cand['ER_pre12'].values - r['ER_pre12']) / sd['ER_pre12']) ** 2
+ ((roa[ok] - r['roa_m']) / sd['roa_m']) ** 2)
keep = dist <= caliper
if not keep.any():
continue
cs, ds, rs = cand[keep], dist[keep], roa[ok][keep]
idx = np.argsort(ds)[:n_match] # up to five nearest
mc, K = cs.iloc[idx], len(idx)
rows.append({'firm': r['Instrument'], 'ym': r['ym'], 'Unprof': int(r['Unprof']),
'diff': r['ER_post12'] - mc['ER_post12'].mean()})
pairs.append((r['Instrument'], r['ym'], list(mc['Instrument'])))
tcov.append({'log_mcap': r['log_mcap'], 'ER_pre12': r['ER_pre12'],
'roa_m': r['roa_m'], 'Unprof': int(r['Unprof'])})
ccov.append({'log_mcap': mc['log_mcap'].mean(), 'ER_pre12': mc['ER_pre12'].mean(),
'roa_m': rs[idx].mean(), 'Unprof': int(r['Unprof'])})
for j, jj in enumerate(idx):
wcov.append({'log_mcap': mc.iloc[j]['log_mcap'], 'ER_pre12': mc.iloc[j]['ER_pre12'],
'roa_m': rs[jj], 'w': 1.0 / K, 'Unprof': int(r['Unprof'])})
return (pd.DataFrame(rows), pairs, pd.DataFrame(tcov),
pd.DataFrame(ccov), pd.DataFrame(wcov))
SD, BK = make_sd(treated, controls), make_buckets(controls)
r_full, pairs, tcov, ccov, wcov = match(treated, BK, SD)
for nm, v in [('All renamers', None), ('Profitable renamers', 0), ('Unprofitable renamers', 1)]:
s = r_full if v is None else r_full[r_full['Unprof'] == v]
print(f'{nm:<24}{s["diff"].median()*100:>+7.1f}{len(s):>10,}')Table 2, Panel B. Matched comparisons. Median paired difference in percentage points.
| Median difference | Matched events | |
|---|---|---|
| All renamers | -4.7 | 1,047 |
| Profitable renamers | -2.8 | 682 |
| Unprofitable renamers | -13.4 | 365 |
The matched estimate and the regression estimate agree at -13.4 percentage points, and the two designs get there on different samples. Matching drops every renamer with no close partner, so 1,047 of the 1,571 events survive. The regression keeps all of them and controls linearly instead.
Balance is what makes a matched comparison worth reading, so I check the covariates inside the unprofitable arm once the matching is done.
def smd(a, b):
"""Standardised mean difference: the gap in pooled standard deviations."""
a, b = a.dropna(), b.dropna()
s = np.sqrt((a.var(ddof=1) + b.var(ddof=1)) / 2)
return (a.mean() - b.mean()) / s if s > 0 else np.nan
def smd_w(t_, c_, w_, v):
"""The same, with each control carrying 1/K inside its own matched set."""
tm, tv = t_[v].mean(), t_[v].var(ddof=1)
cm = np.average(c_[v], weights=w_)
cv = np.average((c_[v] - cm) ** 2, weights=w_)
return (tm - cm) / np.sqrt((tv + cv) / 2)
tu_, cu_, wu_ = (x[x['Unprof'] == 1] for x in (tcov, ccov, wcov))
print(f'{"covariate":<12}{"set-mean":>12}{"1/K weighted":>16}')
for v in ('log_mcap', 'ER_pre12', 'roa_m'):
print(f'{v:<12}{smd(tu_[v], cu_[v]):>+12.3f}{smd_w(tu_, wu_, wu_["w"], v):>+16.3f}')Table 3. Balance after matching, unprofitable arm. Standardised mean differences.
| Covariate | Set-mean | 1/K weighted |
|---|---|---|
| Log market cap | -0.035 | -0.035 |
| Prior 12-month adjusted return | +0.017 | +0.016 |
| Return on assets | -0.011 | -0.011 |
The two weightings agree to within 0.001, and all three covariates sit well inside the 0.1 threshold used as a balance heuristic. That is a diagnostic rather than proof that nothing is left uncontrolled.
Step 8. Measure the same thing six ways
One design is one set of choices, so I ran the same question five more ways: the raw median difference with no matching at all, the two regression specifications, matching without the industry requirement, and matching restricted to each firm’s first rename.
u_ni = match(treated, make_buckets(controls, use_industry=False), SD, use_industry=False)[0]
u_ni = u_ni[u_ni['Unprof'] == 1] # no industry requirement
u_full = r_full[r_full['Unprof'] == 1]
fr = treated.sort_values('ym').groupby('Instrument', as_index=False).first()[['Instrument', 'ym']]
kp = set(zip(fr['Instrument'], fr['ym']))
u_fr = u_full[[(a, b) in kp for a, b in zip(u_full['firm'], u_full['ym'])]] # first rename only
SPEC = [('raw median difference', (tu.median() - cu.median()) * 100),
('matched', u_full['diff'].median() * 100),
('regression, plus size and prior', res['(3)'][0].params['RxU'] * 100),
('matched, no industry', u_ni['diff'].median() * 100),
('matched, first rename only', u_fr['diff'].median() * 100),
('regression, rename and profit only', res['(2)'][0].params['RxU'] * 100)]
for nm, est in SPEC:
print(f'{nm:<36}{est:>+7.1f}pp')
fig, ax = plt.subplots(figsize=(9, 3.9))
for i, (nm, est) in enumerate(SPEC):
ax.plot([0, est], [i, i], color=GREY, lw=1.3, zorder=1)
ax.plot(est, i, 'o', ms=9, color=RED, zorder=3)
ax.annotate(f'{est:.1f}', (est, i), xytext=(-9, 0), textcoords='offset points',
ha='right', va='center', fontsize=9.5, fontweight='bold')
ax.axvline(0, color='#444', lw=1.0)
ax.set_yticks(range(len(SPEC)))
ax.set_yticklabels([s[0] for s in SPEC], fontsize=10)
ax.invert_yaxis()
ax.set_xlabel('unprofitable renamer minus comparable firm, over 12 months (pp)')
ax.set_title('Six ways of measuring it', fontsize=13)
ax.set_xlim(-17, 2)
plt.tight_layout()
plt.savefig('nc_matched.png', dpi=140, bbox_inches='tight', facecolor='white')raw median difference -13.5pp
matched -13.4pp
regression, plus size and prior -13.4pp
matched, no industry -13.8pp
matched, first rename only -12.2pp
regression, rename and profit only -12.1pp

Each grey line starts at zero and ends where one specification leaves the estimate. All six sit between -12.1 and -13.8 percentage points. The three estimates that use no matching sit inside the same band as the three that do, so the result does not come from the matching design.
Step 9. Put an interval on it
Abadie and Imbens (2008) show the ordinary bootstrap can fail for nearest-neighbour matching estimators, because matching is not a smooth function of the data. So I use subsampling instead. Each draw takes a random slice of the treated firms and the same proportion of the control pool, then rebuilds the whole matching problem from scratch, so the uncertainty that comes from choosing partners sits inside the interval.
trt_firms, ctrl_firms = treated['Instrument'].unique(), controls['Instrument'].unique()
nT, nC = len(trt_firms), len(ctrl_firms)
theta = u_full['diff'].median()
rng = np.random.default_rng(42)
out = []
for expo in (0.60, 0.70, 0.80):
bT = int(round(nT ** expo))
bC = int(round(nC * bT / nT)) # the same proportion on both sides
vals = []
for _ in range(400):
pT = set(rng.choice(trt_firms, size=bT, replace=False))
pC = set(rng.choice(ctrl_firms, size=bC, replace=False))
sub_t = treated[treated['Instrument'].isin(pT)]
sub_c = controls[controls['Instrument'].isin(pC)]
if len(sub_t) < 20 or len(sub_c) < 500:
continue
# buckets and standard deviations are rebuilt from the draw, so partners are re-chosen
rr = match(sub_t, make_buckets(sub_c), make_sd(sub_t, sub_c))[0]
if not len(rr):
continue
uu = rr[rr['Unprof'] == 1]
if len(uu) > 5:
vals.append(uu['diff'].median())
root = np.sqrt(bT) * (np.array(vals) - theta) # the subsampling root
lo, hi = np.percentile(root, [97.5, 2.5])
out.append((bT, (theta - lo / np.sqrt(nT)) * 100, (theta - hi / np.sqrt(nT)) * 100))
print(f'b=n^{expo:.2f}: {bT:>4} treated, {bC:>5} controls, '
f'95% [{out[-1][1]:+.1f}, {out[-1][2]:+.1f}] ({len(vals)} draws)')
fig, ax = plt.subplots(figsize=(9, 3.9))
for i, (bT, lo_, hi_) in enumerate(out):
ax.plot([lo_, hi_], [i, i], color=BLUE, lw=3.0, solid_capstyle='round')
ax.plot(theta * 100, i, 'o', ms=10, color=RED, zorder=3)
ax.annotate(f'[{lo_:.1f}, {hi_:.1f}]', (lo_, i), xytext=(-8, 0), textcoords='offset points',
ha='right', va='center', fontsize=10, fontweight='bold')
ax.axvline(0, color='#444', lw=1.4)
ax.set_yticks(range(len(out)))
ax.set_yticklabels([f'{b} treated firms\nof {nT:,}' for b, _, _ in out], fontsize=9.5)
ax.invert_yaxis()
ax.set_xlim(-40, 6)
ax.set_xlabel('95% interval for the unprofitable estimate (pp)')
ax.set_title('The interval stays below zero', fontsize=13)
plt.tight_layout()
plt.savefig('nc_converge.png', dpi=140, bbox_inches='tight', facecolor='white')b=n^0.60: 75 treated, 451 controls, 95% [-33.0, -4.0] (91 draws)
b=n^0.70: 155 treated, 933 controls, 95% [-27.1, -2.9] (395 draws)
b=n^0.80: 319 treated, 1919 controls, 95% [-24.1, -6.0] (400 draws)

Each blue line is the 95% interval at one subsample size, and the red dot is the full-sample estimate of -13.4 that all three are centred on. The intervals narrow as the subsample grows, from 75 treated firms to 319, and all three stay below zero. At the largest of the three the interval runs from -24.1 to -6.0 percentage points, which is far wider than an ordinary bootstrap would report.
A firm-level sign test needs no assumption about the shape of the paired differences, and it is the cleanest evidence here.
fm = u_full.groupby('firm')['diff'].median() # one number per firm, not per event
neg, tot = int((fm < 0).sum()), len(fm)
print(f'{neg} of {tot} negative, p = {stats.binomtest(neg, tot, 0.5).pvalue:.2e}')200 of 332 negative, p = 2.25e-04
Step 10. Test it in calendar time
Everything so far compares twelve-month outcomes across firms. That leaves one objection unanswered. Renames cluster in calendar time, so two events starting a month apart share eleven months of the same market, and treating them as independent overstates precision. Mitchell and Stafford (2000) show how much damage this does in long-horizon event studies.
So I ran the calendar-time version. For each unprofitable rename I hold its matched controls, walk months t+1 to t+12, take the firm’s monthly return minus its controls’ average, and average across every event active in that calendar month. That gives one observation per month, however many events happen to be running.
RET = {(i, y): r for i, y, r in zip(m['Instrument'], m['ym'], m['ret'])}
u_keys = set(zip(u_full['firm'], u_full['ym']))
recs = []
for firm, ym0, ctrls in pairs:
if (firm, ym0) not in u_keys: # unprofitable arm only
continue
for k in range(1, HORIZON + 1): # months t+1 to t+12
ymk = ym0 + k
rt = RET.get((firm, ymk))
rc = [x for x in (RET.get((c, ymk)) for c in ctrls)
if x is not None and not pd.isna(x)]
if rt is None or pd.isna(rt) or not rc:
continue
recs.append({'ym': ymk, 'spread': rt - np.mean(rc), 'firm': firm})
cal = pd.DataFrame(recs)
ser = cal.groupby('ym')['spread'].mean().sort_index() # ONE observation per calendar month
y, X = ser.values, np.ones((len(ser), 1))
print(f'{len(y)} months, median {cal.groupby("ym")["firm"].size().median():.0f} active pairs, '
f'mean monthly spread {y.mean()*100:+.2f}%')
for lags in (0, 6, 12):
fit = (sm.OLS(y, X).fit() if lags == 0 else
sm.OLS(y, X).fit(cov_type='HAC', cov_kwds={'maxlags': lags}))
print(f' Newey-West {lags:>2}: t = {fit.tvalues[0]:>6.2f} p = {fit.pvalues[0]:.4f}')300 months, median 12 active pairs, mean monthly spread -0.57%
Newey-West 0: t = -1.44 p = 0.1502
Newey-West 6: t = -1.52 p = 0.1292
Newey-West 12: t = -1.40 p = 0.1614
The mean monthly spread is -0.57%, and it does not clear significance at any of the three lag lengths. I do not compound it into an annual figure, because a difference between two returns is not the return on a portfolio anyone could hold without saying how it is financed.
So the cross-sectional estimates and the calendar-time one point the same way and disagree about confidence. They answer different questions. The cross-section asks whether a typical unprofitable renamer underperforms a comparable firm, and the answer is yes with an interval clear of zero. Calendar time asks whether a portfolio holding these names against their controls would have earned a reliable spread, and there the answer is no.
Equal weighting across months is why. A month with many active pairs counts the same as a month with few, and that is the point of running the test.
What this does not settle
Deal-driven renames are screened out by code. Wu (2010) reads press releases and filings by hand. My filter flagged 116 events as deal-related, so some may remain in the sample. This is the largest gap against the published work.
The date is the effective date. That is the day the old legal name stopped being valid. The announcement sometimes falls on the same day and sometimes a year earlier, which means these dates cannot support a clean short-window announcement study. Hand-collected announcement dates could.
Matching balances what I can see. Distress I cannot observe could drive both the rename and the return that follows. The defensible verb is predicts.
The 12/12 requirement removes events. Firms that leave the panel inside the window cannot enter, and the data carry no delisting reason, so I cannot tell failure from acquisition. I therefore cannot sign that bias in either direction.
The industry classification is the current one. Firms are matched on the industry group they carry today rather than the one they carried at the time, so a company that changed business before changing its name could be matched on a label it earned later. Dropping the industry requirement entirely gives -13.8pp on 509 events, so this is not what produces the result.
The daily returns are cleaned before compounding. The raw file holds corrupted values reaching +89,900% in a single day, so daily total returns are capped at plus and minus 100% first. Rebuilding both windows under looser caps, and with no cap beyond dropping the corrupted rows, puts the matched estimate near -11 percentage points.
How this sits in the literature
The existing evidence is mixed, and profitability may explain part of why.
Kot (2011) studies Hong Kong renames and finds short-run price reactions with very weak long-run evidence, which is what my pooled result also shows. Wu (2010) finds that firms adopt a radically different name in the wake of a tarnished reputation, and that organisational upheaval follows most name changes. Guo and co-authors (2025) find renaming predicts higher crash risk, through diverted investor attention and greater information asymmetry. Both point in the same direction as the unprofitable subsample here.
Andrikopoulos, Daynes and Pagas (2007) is the closest to a conflict. Across 803 UK renames they find long-run underperformance for firms with both positive and negative pre-event returns, where I find nothing among profitable firms. They split on prior stock returns and I split on the income statement, and my interaction survives controlling for the prior twelve-month return, growing from -12.1pp to -13.4pp.
So the contribution here is about pooling. Putting profitable and unprofitable renamers into one average combines a large difference with one close to zero, and the average that comes out describes neither group.
To conclude
The data cannot see intent. A rename by a struggling firm may be a real change of business, and it may be an attempt to leave a reputation behind. Both look the same in a returns panel.
Nor does this add up to a trading strategy. Across the main cross-sectional specifications the gap stays around 12 to 14 percentage points, and it stays below zero under inference that rebuilds the matching each time. The calendar-time version does not clear significance, though it runs negative in every month-weighted test. So the honest answer to the title is that renaming is a warning worth reading, and I have not shown it is worth trading.
The takeaway is that a name change is not a signal on its own. The first place to look is the last income statement published before it.
Read next
- The art of thinking clearly as a quant Six biases, including the one where an average describes nobody.
- Same data, same trading signal, different answer How much of a result comes from how the test was built.
- Download stock data from LSEG Workspace in Python The pipeline behind the panel used here.
Disclaimer: an empirical study for discussion purposes, not investment advice. The design is predictive and not causal. Past performance is not a guarantee of future returns.
Because the data is licensed, I cannot share the raw file, and every number above is an aggregate.
Sources: YiLin Wu, What’s in a name? What leads a firm to change its name and what the new name foreshadows, Journal of Banking & Finance 34(6), 1344-1359 (2010). Hung Wan Kot, Corporate name changes: Price reactions and long-run performance, Pacific-Basin Finance Journal 19(2), 230-244 (2011). Panagiotis Andrikopoulos, Arief Daynes and Paraskevas Pagas, The Long-Term Market Performance of UK Companies Following Corporate Name Changes, SSRN working paper (2007). Shijun Guo, Daifei Yao, Yefeng Zhang and Yuyu Zhang, Corporate Renaming and Stock Price Crash Risk, Accounting & Finance (2025). Elizabeth Smith and Ruth Malone, Altria means tobacco: Philip Morris’s identity crisis, American Journal of Public Health (2003). Alberto Abadie and Guido Imbens, On the Failure of the Bootstrap for Matching Estimators, Econometrica (2008). Mark Mitchell and Erik Stafford, Managerial Decisions and Long-Term Stock Price Performance, Journal of Business 73(3), 287-329 (2000).