Article

Who Owns Your Company, and What Has Changed?

Retrieve ownership data to identify material changes in an issuer’s reported ownership between two periods

Nick Zincone
Lead Developer Advocate Lead Developer Advocate

Companies use reported ownership data to understand the composition of their shareholder base and to spot changes worth investigating. Investor-relations and corporate teams can use that context to prepare for shareholder engagement, refine investor outreach, and support governance or corporate-planning discussions.

Ownership data brings together reported positions from filings, proxies, fund disclosures, and other sources. Each record identifies an investor's position in a company as of the source report's holdings date, together with the preceding holding observation available for that investor. The data does not explain an investor's decision; it helps a reviewer identify where further research may be useful.

A company's ownership records change as new disclosures become available, positions are updated, and reporting entities are consolidated differently. The useful signal is seeing those reported changes clearly enough to decide that they deserve a closer look.

LSEG Workspace supports interactive exploration of ownership data and its available attributes. This notebook uses the LSEG Data Library for Python to make that data available in a company's own analytical environment, where it can be combined with internal information, shaped by company-specific review rules, and integrated into established processes.

    	
            

# Pull down the required libraries and set up the notebook environment.

import lseg.data as ld

from lseg.data.content import ownership

import pandas as pd

import warnings

 

pd.set_option("display.max_columns", 50)

pd.set_option('future.no_silent_downcasting', True)

warnings.simplefilter("ignore", Warning)

 

ld.__version__

Monitoring Strategy

LSEG Workspace supports interactive investigation when a reviewer wants to explore an issuer or holder in depth. The LSEG Data Library for Python makes the same ownership attributes available as structured data, so a company can combine them with its own information, apply its own review logic, and use the resulting analysis within its established workflows.

The analysis compares two period-end ownership snapshots — the most recent completed period and the one before it — retrieved together in a single request. It follows six steps:

  1. Retrieve the last two period-end ownership snapshots for the issuer in one request.
  2. Check coverage so the comparison never runs on a truncated, partial view.
  3. Compare the two snapshots for each investor to identify material increases and reductions among continuing holders, newly reported positions, and holdings no longer represented.
  4. Prioritize the flagged changes by size and by how recently the holding was reported, so the most significant appear first.
  5. Confirm that a newly reported or absent investor is a genuine change by surfacing its parent organisation, not the same group reported under a different entity.
  6. Deliver the prioritized review list to a report, dashboard, or alert so the relevant team can act on it.

The output is a prioritized list of reported ownership changes between the two periods that merit further attention.

Open the Data Session

The LSEG Data Library for Python can access data through more than one session type. Based on your permissions, users can specify access through a Desktop session: accessing Ownership data through your LSEG Workspace desktop application license, or alternatively, configure a Platform session to access Ownership data via the LSEG Data Platform (RDP). See the session examples and the LSEG Data Library for Python Quick Start for supported session types and configuration guidance.

    	
            

# Establish a session with the LSEG Data Library.

ld.open_session()

Select a Company and Set Parameters

Start by choosing the company to monitor: set its RIC, the instrument identifier used by the ownership request.

The request retrieves period-end snapshots at the chosen frequency — quarterly (Q) uses quarter-end dates, monthly (M) uses month-end. LOOKBACK_PERIODS sets how many of the most recent snapshots to pull; two is enough to compare the latest period with the one before it. The date boundaries are derived from that look-back and end at the most recent completed period, so the same notebook stays current each time it is run.

HISTORY_LIMIT caps the entire response across every period returned. The coverage check that follows halts the notebook if this cap truncates the view.

Two tuning knobs for what counts as "worth reviewing"

The review list is controlled by two separate thresholds, so you can tune each type of change on its own:

  • MIN_OWNERSHIP_CHANGE_PCT — how big a move by an existing holder is worth a look. Continuing investors trim and add to positions all the time, and most of that drift is noise. This is the minimum change in percentage of shares outstanding (e.g. 0.25 = a quarter of a percentage point) before a continuing holder is flagged. Raise it to see only the larger moves; lower it to catch smaller ones.
  • MIN_ENTRY_EXIT_PCT — how big a brand-new or fully-departed holder must be to appear. An investor showing up on the register for the first time, or dropping off it entirely, is a distinct event that many reviewers want to see regardless of size — so this defaults to 0.0, surfacing every appearance and departure. The trade-off is that very large issuers churn through many tiny index-tracking holders each period; if that list becomes noisy, raise this floor (e.g. to 0.1) to keep only entries and exits above a minimum size.

For a focused company like Flux Power, 0.0 comfortably shows every entry and exit. For a mega-cap with thousands of holders, start higher and lower it only if you want the full churn. This notebook uses FLUX.O by default to keep the demonstration clear.

    	
            

# Company-level ownership endpoints use an instrument identifier such as a RIC.

COMPANY_RIC = "FLUX.O"

 

# Derive the company name

df = ownership.org_info.Definition("FLUX.O").get_data()

_company_name = df.data.df["Company Name"][0]

 

# Snapshot frequency: quarterly (Q) uses quarter-end dates, monthly (M) uses month-end.

API_FREQUENCY = ownership.Frequency.QUARTERLY

 

# Number of most-recent period-end snapshots to retrieve; two compares the latest period with the one before it.

LOOKBACK_PERIODS = 2

 

# Derive the request window from the look-back, ending at the most recent completed period.

_period_alias = "QE" if API_FREQUENCY == ownership.Frequency.QUARTERLY else "ME"

_period_ends = pd.date_range(end=pd.Timestamp.today().normalize(), periods=LOOKBACK_PERIODS, freq=_period_alias)

HISTORY_START = _period_ends.min().strftime("%Y-%m-%d")

HISTORY_END = _period_ends.max().strftime("%Y-%m-%d")

 

# Caps the entire response across all periods. The coverage check halts if this truncates the view.

HISTORY_LIMIT = 500

TOP_N = 10

 

# Review thresholds. Ownership changes are percentage points; recency is calendar days.

MIN_OWNERSHIP_CHANGE_PCT = 0.25  # continuing holders: minimum move to flag

# Entries and exits are discrete events, so they use their own floor: 0.0 surfaces every

# appearance and departure; raise it to suppress micro-churn on very large issuers.

MIN_ENTRY_EXIT_PCT = 0.0

MAX_HOLDINGS_AGE_DAYS = 180

 

print(f"Monitoring company : {_company_name} ({COMPANY_RIC})")

print(f"API frequency      : {API_FREQUENCY}")

print(f"Look-back periods  : {LOOKBACK_PERIODS}")

print(f"Date boundaries    : {HISTORY_START} to {HISTORY_END}")

 

print(f"Shared row limit   : {HISTORY_LIMIT:,}")

print(f"Top-N candidates   : {TOP_N}")

Monitoring company : Flux Power Holdings Inc (FLUX.O)
API frequency : Q
Look-back periods : 2
Date boundaries : 2026-03-31 to 2026-06-30
Shared row limit : 500
Top-N candidates : 10

Retrieve a Reported Ownership View

The consolidated shareholder report returns the issuer's reported ownership history across the requested look-back window. For a multi-period request, the same investor can appear in more than one period, with one row for that investor in each period-end snapshot. It is not a transaction feed and it does not establish why an investor acted.

HISTORY_LIMIT caps the entire response across all periods returned. A response that reaches that limit may be incomplete, so its coverage must be validated before treating it as a complete period-by-period view of the issuer's holders.

    	
            

# Retrieve the reported ownership view for the requested look-back window.

history_response = ownership.consolidated.shareholders_history_report.Definition(

    universe=COMPANY_RIC,

    frequency=API_FREQUENCY,

    start=HISTORY_START,

    end=HISTORY_END,

    limit=HISTORY_LIMIT,

).get_data()

 

history_sample = history_response.data.df.copy()

returned_calc_dates = (

    pd.to_datetime(history_sample["Calc Date"]).drop_duplicates().dt.strftime("%Y-%m-%d").tolist()

)

 

print(f"Retrieved {len(history_sample):,} holder records for {COMPANY_RIC}.")

print(f"Returned Calc Date(s): {returned_calc_dates}")

history_sample

Check Coverage Before Trusting the View

HISTORY_LIMIT caps the entire response, and for a multi-period request that cap is shared across every period returned. When the response reaches the cap it is truncated, and — as observed with large issuers — the most recent period fills first while earlier periods are cut short or dropped entirely. An incomplete earlier period would misrepresent the analysis: investors that were present could look absent simply because their rows were dropped.

The check below reports how many investor records were returned for each Calc Date and compares that against the number of periods the request should span. If the response hit the limit, the view is incomplete, so the check halts the notebook with a suggested HISTORY_LIMIT — roughly holders-per-period times the expected number of periods, plus a small headroom — rather than letting the analysis run on truncated data.

    	
            

# Halt if the response was truncated by HISTORY_LIMIT; a partial view would invalidate the analysis.

records_per_period = (

    pd.to_datetime(history_sample["Calc Date"]).dt.strftime("%Y-%m-%d").value_counts().sort_index()

)

response_is_capped = len(history_sample) >= HISTORY_LIMIT

holders_per_period = records_per_period.max()

 

# Size the estimate on the periods the request should span, so a fully dropped period is still counted.

if HISTORY_START is not None:

    period_freq = "QE" if API_FREQUENCY == ownership.Frequency.QUARTERLY else "ME"

    expected_periods = len(pd.date_range(HISTORY_START, HISTORY_END, freq=period_freq))

else:

    expected_periods = len(records_per_period)

 

suggested_limit = int(holders_per_period * expected_periods * 1.1)  # ~10% headroom

 

print(f"Records returned : {len(history_sample):,} (limit {HISTORY_LIMIT:,})")

print(f"Periods returned : {len(records_per_period)} of {expected_periods} expected")

print("Records per period:")

print(records_per_period.to_string())

 

if response_is_capped:

    raise ValueError(

        f"Response truncated at HISTORY_LIMIT={HISTORY_LIMIT:,} ({len(history_sample):,} rows). "

        f"The view is incomplete, so the analysis would be invalid. Raise HISTORY_LIMIT to at least "

        f"~{suggested_limit:,} (~{holders_per_period} holders x {expected_periods} periods + 10% headroom) "

        f"and re-run."

    )

 

print("\nCoverage OK: the response is below the limit, so every returned period is complete.")

 

Records returned : 163 (limit 500)
Periods returned : 2 of 2 expected
Records per period:
Calc Date
2026-03-31 82
2026-06-30 81

Coverage OK: the response is below the limit, so every returned period is complete.

Reading the output

The following table is intentionally limited to the fields that drive the ownership-change analysis. It is not a full schema reference for every column returned by the API. The monitoring logic relies on the issuer's latest and prior period-end snapshots, and on a small set of investor and holding attributes needed to identify, rank, and review the changes.

The raw API response also carries row-level Previous and change fields for a reporting investor's prior filing, but those are contextual metadata rather than the basis of the period-over-period review logic used here. The notebook uses Calc Date, investor identifiers, UltimateParentId, and the snapshot-level ownership percentages when it decides whether a change is material.

Compare the Two Periods to Identify Material Reported Changes

This implements strategy steps 3–5. Using the two period-end snapshots retrieved above, we compare each investor's reported ownership at the most recent period against the period before it and classify the result into one of four reviewer-facing categories:

  • Material reported increase — a continuing investor whose reported ownership rose by at least the threshold.
  • Material reported reduction — a continuing investor whose reported ownership fell by at least the threshold.
  • Newly reported position — an investor holding at the latest period that was not represented in the prior period.
  • No longer represented — an investor represented in the prior period but not at the latest.

The change is computed directly from the two snapshots — the difference in reported percentage of shares outstanding between the period-ends — rather than from the row's own Previous columns, which track an investor's prior filing rather than our chosen periods. Newly reported positions are ranked by their current percentage; positions no longer represented by their prior percentage; continuing investors by the absolute change. Candidates are ordered by that materiality, then by how recently the holding was reported.

Continuing holders must clear the change threshold (MIN_OWNERSHIP_CHANGE_PCT) to be flagged, because small incremental drift is rarely worth review. Entries and exits are treated differently: a holder appearing on or disappearing from the register is a discrete event, so they use their own floor (MIN_ENTRY_EXIT_PCT, 0 by default) and are surfaced regardless of size. Raise that floor to suppress micro-churn on very large issuers.

Because an investor can be reported under related entities, each row carries the investor's UltimateParentId so a reviewer can confirm that a newly reported or absent investor is a genuine change rather than the same group reported differently. The comparison reflects only what is visible in these two snapshots; it does not infer intent, trade dates, or a confirmed sale.

    	
            

COLUMN_MAP = {

    "Calc Date": "calc_date",

    "Investor Id": "investor_id",

    "Investor Name": "investor_name",

    "Investor Parent Type": "investor_parent_type",

    "Investor Type": "investor_type",

    "Investor Region": "investor_region",

    "Orientation": "orientation",

    "UltimateParentId": "ultimate_parent_id",

    "SharesHeld": "shares_held",

    "% SharesOutstanding": "ownership_pct",

    "Holdings Date": "holdings_date",

    "Filing Type": "filing_type",

}

 

work = history_sample.rename(columns=COLUMN_MAP)[list(COLUMN_MAP.values())].copy()

work["shares_held"] = pd.to_numeric(work["shares_held"], errors="coerce")

work["ownership_pct"] = pd.to_numeric(work["ownership_pct"], errors="coerce")

work["calc_date"] = pd.to_datetime(work["calc_date"])

work["holdings_date"] = pd.to_datetime(work["holdings_date"])

 

# The two most recent periods in the response: latest vs the one before it.

periods = sorted(work["calc_date"].unique())

if len(periods) < 2:

    raise ValueError("Need at least two periods to compare; increase LOOKBACK_PERIODS and re-run.")

period_latest, period_prior = periods[-1], periods[-2]

 

def snapshot(period):

    # One row per investor for the period; if an investor appears twice, keep the larger reported line.

    return (

        work[work["calc_date"] == period]

        .sort_values("shares_held", ascending=False)

        .drop_duplicates("investor_id")

        .set_index("investor_id")

    )

 

latest = snapshot(period_latest)

prior = snapshot(period_prior)

 

# Presence is a positive reported holding, so padded zero-rows are not counted as held.

holders_latest = set(latest.index[latest["shares_held"] > 0])

holders_prior = set(prior.index[prior["shares_held"] > 0])

 

# Attributes prefer the latest period, falling back to the prior period for investors no longer represented.

attrs = pd.concat([latest, prior])

attrs = attrs[~attrs.index.duplicated(keep="first")]

 

review = pd.DataFrame(index=attrs.index)

investor_name = attrs["investor_name"].astype("string")

display_name = investor_name.where(

    investor_name.notna() & ~investor_name.astype(str).str.lower().eq("<na>"),

    attrs.index.to_series().astype(str),

)

review["investor_name"] = display_name

review["ultimate_parent_id"] = attrs["ultimate_parent_id"]

review["investor_type"] = attrs["investor_type"]

review["investor_region"] = attrs["investor_region"]

review["orientation"] = attrs["orientation"]

review["ownership_pct"] = latest["ownership_pct"].reindex(review.index)

review["previous_ownership_pct"] = prior["ownership_pct"].reindex(review.index)

review["ownership_change_pp"] = (

    review["ownership_pct"].fillna(0) - review["previous_ownership_pct"].fillna(0)

)

review["holdings_date"] = latest["holdings_date"].reindex(review.index).fillna(

    prior["holdings_date"].reindex(review.index)

)

review["holdings_age_days"] = (period_latest - review["holdings_date"]).dt.days

review["is_stale_holding"] = review["holdings_age_days"] > MAX_HOLDINGS_AGE_DAYS

 

def review_category(investor_id):

    # Continuing holders use the change threshold; entries/exits use their own (smaller) floor.

    now, before = investor_id in holders_latest, investor_id in holders_prior

    change = review.at[investor_id, "ownership_change_pp"]

    current = review.at[investor_id, "ownership_pct"]

    previous = review.at[investor_id, "previous_ownership_pct"]

    if now and not before:

        return "Newly reported position" if pd.notna(current) and current >= MIN_ENTRY_EXIT_PCT else pd.NA

    if before and not now:

        return "No longer represented" if pd.notna(previous) and previous >= MIN_ENTRY_EXIT_PCT else pd.NA

    if pd.notna(change) and change >= MIN_OWNERSHIP_CHANGE_PCT:

        return "Material reported increase"

    if pd.notna(change) and change <= -MIN_OWNERSHIP_CHANGE_PCT:

        return "Material reported reduction"

    return pd.NA

 

review["review_category"] = [review_category(i) for i in review.index]

 

# Materiality: entries by current %, exits by prior %, continuing by absolute change.

review["materiality_pp"] = review["ownership_change_pp"].abs()

review.loc[review["review_category"] == "Newly reported position", "materiality_pp"] = review["ownership_pct"]

review.loc[review["review_category"] == "No longer represented", "materiality_pp"] = review["previous_ownership_pct"]

 

period_changes = (

    review[review["review_category"].notna()]

    .sort_values(["materiality_pp", "holdings_date"], ascending=[False, False])

    .reset_index()

    .rename(columns={"index": "investor_id"})

)

period_changes.index += 1

period_changes.index.name = "Priority"

 

print(f"Comparing periods              : {period_prior.date()} -> {period_latest.date()}")

print(f"Investors at latest period     : {len(holders_latest)}")

print(f"Investors at prior period      : {len(holders_prior)}")

print(f"Reported changes for review    : {len(period_changes)}")

print(f"Change threshold (continuing)  : {MIN_OWNERSHIP_CHANGE_PCT:.2f} pp")

print(f"Entry/exit threshold           : {MIN_ENTRY_EXIT_PCT:.2f} pp")

if len(period_changes):

    print("By category:")

    print(period_changes["review_category"].value_counts().to_string())

period_changes[[

    "investor_id", "investor_name", "review_category", "materiality_pp",

    "previous_ownership_pct", "ownership_pct", "ownership_change_pp",

 

    "ultimate_parent_id", "investor_type", "investor_region", "orientation",

 

    "holdings_date", "holdings_age_days", "is_stale_holding",]].head(TOP_N)

Investor watchlist summary

A concise presentation summary helps analysts absorb the most important changes quickly without losing the detail available in the underlying dataframe. This view prioritizes the most material reported changes and keeps the signal readable for a business audience.

The table below groups the flagged investors into a simple summary: the investor name, the category of change, the movement in percentage terms, and the prior/current ownership percentages.

    	
            

# Compact summary table for the article and dashboard output

 

summary_table = (

    period_changes[["investor_name", "review_category", "ownership_change_pp", "previous_ownership_pct", "ownership_pct"]]

    .rename(columns={

        "investor_name": "Investor",

        "review_category": "Strategy",

        "ownership_change_pp": "Change (pct)",

        "previous_ownership_pct": "Prior holding (%)",

        "ownership_pct": "Current holding (%)",

    })

    .head(TOP_N)

    .reset_index(drop=True)

    .copy()

)

 

summary_table.index = range(1, len(summary_table) + 1)

summary_table.index.name = "#"

 

summary_table.style.format({

    "Change (pct)": lambda v: f"{v:+.2f}" if pd.notna(v) else "-",

    "Prior holding (%)": lambda v: f"{v:.2f}" if pd.notna(v) else "-",

    "Current holding (%)": lambda v: f"{v:.2f}" if pd.notna(v) else "-",

    "Strategy": lambda v: v if pd.notna(v) else "-",

}).set_table_styles([

    {"selector": "th", "props": [("text-align", "left")]},

    {"selector": "td", "props": [("text-align", "left")]}

]).set_properties(**{"text-align": "left"})

Summary

This workflow creates a prioritized watchlist of ownership movements by comparing the latest reporting period with the one before it. In practical terms, it helps an analyst quickly answer three questions: who materially changed their position, how large was the move, and whether the change looks like a new entry, a reduction, or a disappearance from the register.

Once the watchlist is assembled, it becomes a useful triage tool for investor relations, governance, and corporate research teams. It narrows a large ownership dataset into a readable set of candidates that deserve attention, while still preserving the underlying detail for deeper follow-up. Analysts can use it to reconcile investor changes, review parent-level representation, and focus their time on the most relevant holdings.

In a typical operating cycle, this workflow is run on a regular cadence such as monthly or quarterly, depending on the issuer, the turnover in the shareholder base, and the level of monitoring required. For high-activity names, monthly review is often valuable; for steadier registries, a quarterly review may be sufficient.

  • Register or Log in to applaud this article
  • Let the author know how much this article helped you
If you require assistance, please contact us here