No description has been provided for this image

RepRisk's Investment Score configurator

Based on RepRisk incidents


1. Introduction
2. Data Feeds
3. Step I: Incident score
4. Step II: Raw Investment Score by company
5. Step III: Scaling of raw Investment Scores
6. Investment scores
7. Investment sub-scores
8. UNGC investment scores
9. SASB investment scores
10. Trading strategy example

1. Introduction

This is a straightforward guide on how to calculate and customize Investment scores as a basis for business conduct ratings and trading signals for the equity and bond portfolio construction.

After subscribing to RepRisk’s Data Feeds, the Python code below can be used for building customized Investment scores and ratings.

Go to the interactive examples

Typical client use cases:

  • Systematic Trader – add new alpha sources long/short
  • Portfolio Manager – add portfolio construction signals
  • Fund Manager – rate equity or bond funds
  • Equity Index – example SPDR S&P 500 RepRisk UCITS ETF
  • Sin-Portfolios – driven by business conduct (not by exclusion)

With this tool, clients can:

  • increase the investment universe by re-weighting the different components;
  • build business conduct-tilted products (e.g., ETFs, funds);
  • construct and monitor portfolios in compliance with client requests and specifications;
  • generate any custom factors (e.g., factors based on the SDG 5, i.e., Gender equality, or the UNGC Labour Principles 3 to 6);
  • create trading signals.

2. Data Feeds

Back to top

Clients have access to the incident-level data through:

  • RepRisk's daily Data Feeds (FTP)
  • API (e.g., Factset, Crux Informatics, Battlefin and others)

Read RepRisk’s full methodology.

Each incident in the RepRisk Dataset is rated based on severity, reach, and novelty:

  • Severity [1, 2, 3] reflects the harshness of the risk incident or criticism; where [3] denotes a very severe risk incident or criticism and [1] denotes low severity.
  • Reach [1, 2, 3] of the information source (influence based on readership/circulation as well as by its importance in a specific country); where [3] denotes high reach and [1] denotes low reach.
  • Novelty [1, 2] reflects the newness of the issues addressed for the company, i.e., whether it is the first time a company/project is exposed to a specific RepRisk Issue in a certain location; where [2] denotes the first time a company is exposed to this issue and [1] denotes repeated occurrences.

The code section below loads all risk incidents, calculates their incident scores, and outputs the data example in the Incident Level Data table.

In [1]:
# load risk incidents from database
import pandas as pd
import numpy as np
import warnings
import plotly.io as pio

pio.renderers.default = "notebook_connected"
warnings.filterwarnings("ignore")
pd.options.mode.chained_assignment = None  

# load Incident File
incident_data = pd.read_csv('221228101343_IncidentFileV2_Dec22_LBr.zip', sep=";")
incident_data = incident_data.drop_duplicates(subset=['Company ID', 'Story ID'])
incident_data['Incident Date'] = pd.to_datetime(incident_data['Incident Date'])
incident_data['Severity'].replace({0: 1}, inplace=True)
incident_data['Reach'].replace({0: 1}, inplace=True)
incident_data['Novelty'].replace({0: 1}, inplace=True)

# calculate incident scores
severity_weights = {1: 1, 
                    2: 10, 
                    3: 100}
reach_weights = {1: 1, 
                 2: 2, 
                 3: 3}
novelty_weights = {1: 1,
                   2: 2}

incident_data["Incident Score"] = (incident_data['Severity'].replace(severity_weights)
                                   *incident_data['Reach'].replace(reach_weights)
                                   *incident_data['Novelty'].replace(novelty_weights))

# prepare for displaying data feeds
incident_v2 = incident_data
cols = incident_v2.columns.tolist()
incident_v2 = incident_v2[cols[0:3] + cols[4:9] + cols[-1:] + cols[9:30] + cols[32:-1]] 
In [2]:
pd.options.display.max_columns = None  
displayDF = incident_v2[pd.notnull(incident_v2['Related Countries']) &
                        incident_v2['Related Countries'].str.contains(';')].sample(2).copy()
displayDF = pd.concat([displayDF, incident_v2.sample(4)], ignore_index=True)
incident_v2 = []
display(displayDF.sample(6))
Company ID Story ID Incident Date Related Countries Related Countries Codes Severity Reach Novelty Incident Score ENVIRONMENT SOCIAL GOVERNANCE CROSS_CUTTING Animal mistreatment Anti-competitive practices Child labor Climate change, GHG emissions, and global pollution Controversial products and services Corruption, bribery, extortion and money laundering Discrimination in employment Executive compensation issues Forced labor Fraud Freedom of association and collective bargaining Human rights abuses and corporate complicity Impacts on communities Impacts on landscapes, ecosystems and biodiversity Local participation issues Local pollution Misleading communication Overuse and wasting of resources Poor employment conditions Products (health and environmental issues) Social discrimination Supply chain issues Tax evasion Tax optimization Violation of international standards Violation of national legislation Waste issues Abusive/Illegal fishing Access to products and services Agricultural commodity speculation Airborne pollutants Alcohol Animal transportation Arctic drilling Asbestos Automatic and semi-automatic weapons Biological weapons Chemical weapons Cluster munitions Coal-fired power plants Conflict minerals Coral reefs Cyberattack Deep sea drilling Depleted uranium munitions Diamonds Drones Economic impact Endangered species Energy management Epidemics/Pandemics Forest burning Fracking Fur and exotic animal skins Gambling Gender inequality Genetically modified organisms (GMOs) Genocide/Ethnic cleansing Greenhouse gas (GHG) emissions Health impact High conservation value forests Human trafficking Hydropower (dams) Illegal logging Indigenous people Involuntary resettlement Land ecosystems Land grabbing Land mines Lobbying Marijuana/Cannabis Marine/Coastal ecosystems Migrant labor Monocultures Mountaintop removal mining Negligence Nuclear power Nuclear weapons Offshore drilling Oil sands Opioids Palm oil Plastics Pornography Predatory lending Privacy violations Protected areas Racism/Racial inequality Rare earths Salaries and benefits Sand mining and dredging Seabed mining Security services Ship breaking and scrapping Soy Tax havens Tobacco Wastewater management Water management Water scarcity UNGC Principle 1 (Human Rights) UNGC Principle 2 (Human Rights) UNGC Principle 3 (Labour) UNGC Principle 4 (Labour) UNGC Principle 5 (Labour) UNGC Principle 6 (Labour) UNGC Principle 7 (Environment) UNGC Principle 8 (Environment) UNGC Principle 9 (Environment) UNGC Principle 10 (Anti-corruption) SASB Issue (GHG Emissions) SASB Issue (Air Quality) SASB Issue (Energy Management) SASB Issue (Water & Wastewater Management) SASB Issue (Waste & Hazardous Materials Management) SASB Issue (Ecological Impacts) SASB Issue (Human Rights & Community Relations) SASB Issue (Customer Privacy) SASB Issue (Data Security) SASB Issue (Access & Affordability) SASB Issue (Product Quality & Safety) SASB Issue (Customer Welfare) SASB Issue (Selling Practices & Product Labeling) SASB Issue (Labor Practices) SASB Issue (Employee Health & Safety) SASB Issue (Employee Engagement, Diversity & Inclusion) SASB Issue (Product Design & Lifecycle Management) SASB Issue (Supply Chain Management) SASB Issue (Business Ethics) SASB Issue (Competitive Behavior) SASB Issue (Management of the Legal & Regulatory Environment) SDG 1 - No Poverty SDG 2 - Zero Hunger SDG 3 - Good Health and Well-being SDG 4 - Quality Education SDG 5 - Gender Equality SDG 6 - Clean Water and Sanitation SDG 7 - Affordable and Clean Energy SDG 8 - Decent Work and Economic Growth SDG 9 - Industry, Innovation and Infrastructure SDG 10 - Reduced Inequalities SDG 11 - Sustainable Cities and Communities SDG 12 - Responsible Consumption and Production SDG 13 - Climate Action SDG 14 - Life Below Water SDG 15 - Life On Land SDG 16 - Peace, Justice and Strong Institutions SDG 17 - Partnerships for the Goals NACE Sector Availability
2 58 2004420 2020-04-19 Egypt;Iran (Islamic Republic of);Iraq;Israel;K... EG;IR;IQ;IL;KW;PS;SA;SY;GB;VE 1 1 2 2 F F T T F T F F F T F F F T F F F F F F F F F F F T F F F T F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F T F F F F F F F F F F F F F F F F F T F F F F F F F F F F F F F F F F F F T F T
4 131231 3784120 2021-02-02 United States of America US 2 3 1 30 F F T T F F F F F F F F F T F F F F F F F F F F F F F F F T F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F T F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F T F T
1 168124 138130 2015-10-06 United States of America;Unspecified US; 2 3 2 60 F T F T F F F F F F F F F F F F F F F F F F F T F F F F F T F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F T F F F F F F F F F F F F F F F F F F F F F F F T F F F F F F F F T F F F F T F F F F F F F F F T
3 318082 8274177 2022-06-24 Germany DE 1 2 1 2 F F T T F F F F F F F F F F F F F F F F T F F F F F F F F T F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F T F T
0 2449980 8204276 2021-12-07 Canada;China CA;CN 1 2 2 4 F F F T F F F F F F F F F F F F F F F F F F F T F F F F F T F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F
5 2341 239670 2017-07-26 Canada CA 1 2 1 2 T T F F F F F F F F F F F F F F T T F T F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F T T T F F F F F F F F F F F F F F F F F F F F F F F F F F F F F F T F F T F F F F F T

3. Step I: Incident score

Back to top

We estimate that each incident reflects a company's business conduct risk exposure, and that its risk increases with the rising incident level. The incident score is thus calculated as follows:


\begin{align} \text{Incident score} &= \text{Severity weight} \times \text{Reach weight} \times \text{Novelty weight}\\ \end{align}


Severity level Severity weight
3 (Very severe incident) 100
2 (Severe incident) 10
1 (Less severe incident) 1
Reach level Reach weight
3 (High reach source) 3
2 (Medium reach source) 2
1 (Limited reach source) 1
Novelty level Novelty weight
2 (Novel) 2
1 (Repeated) 1

3.1. Incident score example

  • Severity = 3
  • Reach = 2
  • Novelty = 2

\begin{align} \text{Incident score} &= \text{Severity weight} \times \text{Reach weight} \times \text{Novelty weight}\\ &= 100 \times 2 \times 2 \\ &=400 \end{align}

Alternatively, the initial incident score can be calculated using an arithmetic or geometric mean:


\begin{align} \text{Arithmetic Incident score} &= \frac{1}{3}( \text{exp(3)} \times \text{Severity level} + \text{exp(2)} \times \text{Reach level} + \text{exp(1)} \times \text{Novelty level})\\ \end{align}

\begin{align} \text{Geometric Incident score} &= \sqrt[3]{( \text{exp(Severity level)} \times \text{exp(Reach level)} \times \text{exp(Novelty level)} }\\ \end{align}

4. Step II: Raw Investment Score by company

Back to top

We estimate that internal business processes causing business conduct violations need an average time of two years to be mitigated.

To calculate the current Investment Score of a company, the past two years of incident scores are time-weighted:


\begin{align} \text{Raw Investment score} &= \sum_{i} \text{Time weight}_{i} \times \text{Incident score}_{i}\\ \end{align}

In [3]:
# calculate and plot time weights
import plotly.express as px
n_years = 2
n_days = 365*n_years
curvature_ = 1
weights = np.arange(1,n_days + 1)
weights = weights/weights.max()
weights = ((2**weights) - 1)**curvature_
time_weights = pd.Series(weights)

fig = px.line(x=time_weights.index, y=time_weights)
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'),
                  xaxis=dict(title="Day in a two-year window"),
                  yaxis=dict(title="Time weight"),
                  template="plotly_white",
                  margin=dict(l=200, r=200),
                  height=400)
fig.show()

4.1. Raw Investment Score (calculated for selected companies)

This graph shows the raw scores calculated for a selected set of listed companies.

In [4]:
# plot raw Investment score for sample companies
import plotly.graph_objects as go

companies_table = pd.read_excel("ticker_data/company_list.xlsx")
companies_table['Name'] = companies_table['Platform Name'].str.split("(").str[0].str.strip()
companies_table.set_index("Name", inplace=True)
companies = companies_table["Company ID"]
start = pd.Timestamp("2007-01-01")
todate = max(incident_data['Incident Date']).strftime('%Y-%m-%d')
end = pd.Timestamp(todate)
date_range_extended = pd.date_range(start=start-pd.Timedelta(days=n_days), end=end)
date_range = pd.date_range(start=start, end=end)
incident_data.set_index("Company ID", inplace=True)

def get_raw_scores(id_, incident_data, col="Incident Score"):
    try:
        company_incidents = incident_data.reset_index().set_index('Incident Date')
        company_incidents = company_incidents[company_incidents['Company ID']==id_]
        incident_scores = company_incidents[col].groupby(level=0).sum().reindex(date_range_extended).fillna(0)
        stacked_incident_score = incident_scores.rolling(n_days).apply(lambda scores: np.dot(scores, weights)).fillna(0)
        stacked_incident_score = stacked_incident_score.reindex(date_range)
    except KeyError:
        stacked_incident_score = pd.Series(0, index=date_range)
    stacked_incident_score.name = "Incident Score"
    return stacked_incident_score  


colors = px.colors.qualitative.Dark24*2
fig = go.Figure()
for name, id_ in companies.items():
    series = get_raw_scores(id_, incident_data)
    fig.add_trace(go.Scatter(x=series.index, y=series.values, name=name,
                  line=dict(color=colors.pop())))
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'),
                  xaxis_rangeslider_visible=True,
                  legend=dict(title=dict(text=" ", side='top'), x=0, y=0.99, 
                              orientation="h", bgcolor='rgba(0,0,0,0)'),
                  template="plotly_white",
                  margin=dict(l=20, r=20, b=10),
                  height=600,
                  title=dict(text='<b>Raw Investment Scores</b>',
                             y=0.99, x=0,
                             xanchor='left',
                             yanchor='top'))
fig.show()

5. Step III: Scaling of raw Investment Scores to [0, 100]

Back to top

The graph showing the raw Investment scores reflects notably that:

  • large companies tend to be linked to more incidents and get more media attention.
  • there seems to be a general increase in incidents or reporting over time.

To make the Investment scores comparable and also aggregate them on a sector, industry, or country level, the raw Investment scores have to be scaled to [0, 100].

There are many ways to scale or map the raw Investment scores, however, in line with the incident and raw Investment score calculation, an exponential cumulative distribution function is used for scaling, as shown below.

In addition, the decay of the score is limited to a maximum of 50% per year (corresponding to a minimum half-life period of one year).

In [5]:
# exponential cumulative distribution function
def scale(series, lambda_=0.000105, curvature=5.3):
    return 100*((1 - np.exp(-lambda_*series))**(1/curvature))

scaling = scale(pd.Series(range(750)))

fig = px.line(x=scaling.index, y=scaling)
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'),
                  xaxis=dict(title="Raw Investment Score"),
                  yaxis=dict(title="Investment Score"),
                  template="plotly_white",
                  margin=dict(l=200, r=200),
                  height=400)
fig.show()
In [6]:
def max_decay(series, decay=0.5**(1/365)):
    new_series = pd.Series(index=series.index, dtype="float")
    previous = 0
    for date, value in series.items():
        if previous > 0:
            if value/previous < decay:
                new_series[date] = previous*decay
            else:
                new_series[date] = value
        else:
            new_series[date] = value
            
        previous = new_series[date]
    return new_series

6. Investment scores

Back to top

As a result of the previous step, the scaled Investment scores are more regular and more comparable with respect to size over time:

  • Companies with a high number of incidents have been compressed.
  • Companies with a smaller number of incidents have been scaled up.
  • Incidents are more comparable over time.

The Investment scores can be extended with a systematic adjustment of size / market cap and location bias (see RepRisk Due Diligence Score). For example, larger companies are typically more in the spotlight and receive more coverage than smaller ones. In addition, companies in countries with high business conduct risk awareness and countries where freedom of the media is guaranteed are more likely to be scrutinized. To prevent the count from being biased for large companies and those located in specific countries, the RepRisk Due Diligence Score applies a normalization factor to the count of less severe (severity 1) incidents.

In [7]:
# plot outside-in score for sample companies
colors = px.colors.qualitative.Dark24*2
fig = go.Figure()

for name, id_ in companies.items():
    series = get_raw_scores(id_, incident_data)
    series = max_decay(scale(series))
    fig.add_trace(go.Scatter(x=series.index, y=series.values, name=name,
                  line=dict(color=colors.pop())))
fig.update_traces(hovertemplate='Investment score: %{y}')
fig.update_yaxes(tickfont=dict(color='#231F20'))
fig.update_xaxes(tickfont=dict(color='#231F20'))
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'),
                  xaxis_rangeslider_visible=True,
                  legend=dict(title=dict(text=" ", side='top'), x=0, y=0.99, 
                              orientation="h", bgcolor='rgba(0,0,0,0)'),
                  legend_font_color="#231F20",
                  template="plotly_white",
                  margin=dict(l=20, r=20, b=10),
                  autosize=True,
                  title=dict(text='<b>Investment Scores</b>',
                             y=0.99, x=0,
                             xanchor='left',
                             yanchor='top'),
                  title_font_family="UnitWeb, UnitOT, Verdana",
                  title_font_color="#231F20",
                  title_font_size=18)
fig.show()
fig.write_html('images/investment_score_plot.html', config={'displayModeBar': False})

7. Investment sub-scores

Back to top

Each incident is distinctly mapped to one or more characteristics such as:

  • Environmental, Social, Governance, or Cross-cutting
  • 28 RepRisk Issues
  • over 73 topics
  • UNGC, SASB, and SDGs

Therefore, it is possible to build customized risk incident sub-scores based on individual or a combination of characteristics.

The example below uses Volkswagen for showing the Investment scores for the All Issues, Environmental, Social, Governance, and Cross-cutting characteristics.

Notes:

  • Since the incidents are not exclusively related to one category, the sub-scores may not add up to the overall score.
  • The overall Investment score represents an upper bound (i.e., the sub-scores cannot exceed the overall score).
In [8]:
# calculate and plot Investment sub-scores for Volkswagen AG
categ = ["ENVIRONMENT", "SOCIAL", "GOVERNANCE", "CROSS_CUTTING"]

# Investment sub-scores
name, id_ = "All Issues", 370
colors = ['#B8B8B8', "#003755", "#2E91E5", "#FBC82D", "#449847"]
fig = go.Figure()
series = get_raw_scores(id_, incident_data)
series = max_decay(scale(series))
fig.add_trace(go.Scatter(x=series.index, y=series.values, name=name,
              line=dict(color='#EB0C0C')))

for subscore in categ:
    series = get_raw_scores(id_, incident_data[incident_data[subscore] == 'T'])
    series = max_decay(scale(series))
    fig.add_trace(go.Scatter(x=series.index, y=series.values, name=subscore,
                  line=dict(color=colors.pop())))
fig.update_traces(hovertemplate='Investment score: %{y}')   
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'),
                  xaxis_rangeslider_visible=True,
                  legend=dict(title=dict(text=" ", side='top'), x=0, y=1.1, 
                              orientation="h", bgcolor='rgba(0,0,0,0)'),
                  legend_font_color="#231F20",
                  template="plotly_white",
                  margin=dict(l=20, r=20, b=10),
                  height=500,
                  title=dict(text='<b>Volkswagen AG: Investment Scores</b>',
                             y=0.99,
                             x=0,
                             xanchor='left',
                             yanchor='top'),
                  title_font_family="UnitWeb, UnitOT, Verdana",
                  title_font_color="#231F20",
                  title_font_size=18)

fig.show()

8. UNGC investment scores

Back to top

The example below uses Volkswagen for showing the Investment scores for all ten UNGC Principles both overall and individually.

Notes:

  • Since the incidents are not exclusively related to one of the UNGC Principles, the sub-scores may not add up to the overall score.
  • The overall Investment score (All Issues) represents an upper bound (i.e., the sub-scores cannot exceed the overall score).
In [9]:
# calculate and plot UNGC sub-scores for Volkswagen AG
ungcprinciples = ['UNGC Principle 1 (Human Rights)', 'UNGC Principle 2 (Human Rights)',
   'UNGC Principle 3 (Labour)', 'UNGC Principle 4 (Labour)',
   'UNGC Principle 5 (Labour)', 'UNGC Principle 6 (Labour)',
   'UNGC Principle 7 (Environment)', 'UNGC Principle 8 (Environment)',
   'UNGC Principle 9 (Environment)', 'UNGC Principle 10 (Anti-corruption)']

# UNGC sub-scores
name, id_ = "UNGC (ALL)", 370
colors = px.colors.qualitative.Dark24*2
fig = go.Figure()

# UNGC (ALL)
series = get_raw_scores(id_, incident_data[incident_data.filter(regex='UNGC').replace({'T': 1, 'F': 0}).any(axis=1)])
series = max_decay(scale(series))
fig.add_trace(go.Scatter(x=series.index, y=series.values, name=name,
              line=dict(color='#A60808')))
# All Issues score
series = get_raw_scores(id_, incident_data)
series = max_decay(scale(series))
fig.add_trace(go.Scatter(x=series.index, y=series.values, name="All Issues",
              line=dict(color='#EB0C0C')))

for subscore in ungcprinciples:
    series = get_raw_scores(id_, incident_data[incident_data[subscore] == 'T'])
    series = max_decay(scale(series))
    fig.add_trace(go.Scatter(x=series.index, y=series.values, name=subscore,
                  line=dict(color=colors.pop())))
fig.update_traces(hovertemplate='Investment score: %{y}')    
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'), 
                  xaxis_rangeslider_visible=True,
                  legend=dict(title=dict(text=" ", side='top'), x=0, y=0.99, 
                              orientation="h", bgcolor='rgba(0,0,0,0)'),
                  template="plotly_white",
                  margin=dict(l=20, r=20, b=10),
                  height=530,
                  title=dict(text='<b>Volkswagen AG: UNGC Scores</b>',
                             y=0.99, x=0,
                             xanchor='left',
                             yanchor='top',
                             font_family="UnitWeb, UnitOT, Verdana",
                           font_color="#231F20",
                           font_size=18))

fig.show()

9. SASB investment scores

Back to top

The example below uses Volkswagen for showing the Investment scores for all 21 SASB Issues both overall and individually.

Notes:

  • Since the incidents are not exclusively related to one of the SASB Issues, the sub-scores may not add up to the overall score.
  • The overall Investment score (All Issues) represents an upper bound (i.e., the sub-scores cannot exceed the overall score).
In [10]:
# calculate and plot SASB sub-scores for Volkswagen AG
sasbissues = ['SASB Issue (GHG Emissions)', 'SASB Issue (Air Quality)',
   'SASB Issue (Energy Management)', 'SASB Issue (Water & Wastewater Management)',
   'SASB Issue (Waste & Hazardous Materials Management)', 'SASB Issue (Ecological Impacts)',
   'SASB Issue (Human Rights & Community Relations)', 'SASB Issue (Customer Privacy)', 
   'SASB Issue (Data Security)', 'SASB Issue (Access & Affordability)',
   'SASB Issue (Product Quality & Safety)', 'SASB Issue (Customer Welfare)',
   'SASB Issue (Selling Practices & Product Labeling)', 'SASB Issue (Labor Practices)', 
   'SASB Issue (Employee Health & Safety)', 'SASB Issue (Employee Engagement, Diversity & Inclusion)',
   'SASB Issue (Product Design & Lifecycle Management)', 'SASB Issue (Supply Chain Management)', 
   'SASB Issue (Business Ethics)', 'SASB Issue (Competitive Behavior)',
   'SASB Issue (Management of the Legal & Regulatory Environment)']

# SASB sub-scores
name, id_ = "SASB (ALL)", 370
colors = px.colors.qualitative.Dark24*2
fig = go.Figure()

# SASB (ALL)
series = get_raw_scores(id_, incident_data[incident_data.filter(regex='SASB I').replace({'T': 1, 'F': 0}).any(axis=1)])
series = max_decay(scale(series))
fig.add_trace(go.Scatter(x=series.index, y=series.values, name=name,
              line=dict(color='#A60808')))
# All Issues score
series = get_raw_scores(id_, incident_data)
series = max_decay(scale(series))
fig.add_trace(go.Scatter(x=series.index, y=series.values, name="All Issues",
              line=dict(color='#EB0C0C')))

for subscore in sasbissues:
    series = get_raw_scores(id_, incident_data[incident_data[subscore] == 'T'])
    series = max_decay(scale(series))
    fig.add_trace(go.Scatter(x=series.index, y=series.values, name=subscore,
                  line=dict(color=colors.pop())))
fig.update_traces(hovertemplate='Investment score: %{y}')  
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'), 
                  xaxis_rangeslider_visible=True,
                  legend=dict(title=dict(text=" ", side='top'), x=0, y=0.99, 
                              orientation="h", bgcolor='rgba(0,0,0,0)'),
                  template="plotly_white",
                  margin=dict(l=20, r=20, b=10),
                  height=600,
                  title=dict(text='<b>Volkswagen AG: SASB Scores</b>', 
                             y=0.99, x=0,
                             xanchor='left',
                             yanchor='top',
                             font_family="UnitWeb, UnitOT, Verdana",
                             font_color="#231F20",
                             font_size=18))

fig.show()

10. A simple trading strategy based on crossovers of Moving Averages (MA)

Trading strategy based on reputational risk incidents

Back to top

Asset managers use several factors to slice and dice market and company information. The Investment scores can provide an additional dimension.
The code below calculates the MA for the scaled Investment scores.

In [11]:
from plotly.subplots import make_subplots

def adjust_trading_signal(score_step, date_range):
    signal_adj = dict()
    prev_value = False
    date = score_step.index.min()
    while date <= score_step.index.max():
        value = (score_step == score_step.max())[date]
        if value != prev_value:
            # trading signal: roll date over to a business day
            date = pd.offsets.BusinessDay().rollforward(date)
            if value == True: 
                # buy signal
                # shift by one day (to when the daily return will count/be considered)
                date += pd.Timedelta(days=1)
                # roll date over to a business day
                date = pd.offsets.BusinessDay().rollforward(date)
                # log the signal
                signal_adj[date], prev_value = True, True
                prev_value = True
                date += pd.Timedelta(days=1)
            else: 
                # sell signal
                # shift by one day
                date += pd.Timedelta(days=1)
                # roll date over to a business day
                date = pd.offsets.BusinessDay().rollforward(date)
                # log the signal
                signal_adj[date], prev_value = False, False
                date += pd.Timedelta(days=1)
        else:
            # no trading signal
            signal_adj[date] = prev_value
            date += pd.Timedelta(days=1)
    return pd.Series(signal_adj).reindex(date_range).ffill().fillna(False)

def get_highlow(pulse):
    margin = 0.40
    high = pulse.max()
    low = pulse.min()
    range_ = high - low
    high += -margin*range_
    low += margin*range_
    return {"high": high, "low": low}

def getbinarysignal(fastmoving, slowmoving, pulse):
    step = fastmoving - slowmoving
    step = step.where(step <= 0, get_highlow(pulse)["low"])
    step = step.where(step > 0, get_highlow(pulse)["high"])
    return step

def button(i, name, annotation, n_traces, n_dropdowns):
    return dict(label=name,
                method="update",
                args=[{"visible": [False]*n_traces*i + [True]*n_traces + [False]*n_traces*(n_dropdowns-1-i)},
                      {"title": None,
                       "annotations": [annotation]}])

def calculate_sharpe_ratio(daily_returns):
    daily_returns = daily_returns.dropna()
    try:
        return (daily_returns.mean()/daily_returns.std())*(252**0.5)
    except ZeroDivisionError:
        return 0
    
def sharpe_ratio_annotation(name, sr_price, sr_invstrategy):
    return dict(
            x=1,
            y=1.188,
            showarrow=False,
            text="<b>Sharpe Ratios</b><br>Stock Price: {:.2f}<br>{strategy_name}Strategy: {:.2f}".format(
                sr_price, sr_invstrategy, strategy_name=name),
            xref="paper",
            yref="paper",
            align="left",
            font=dict(size=14))

line_specs = {"Stock Price": dict(
                    color='black'),
              "All Issues Investment Score": dict(
                    color='#EB0C0C', width=1),
              "G Investment Score": dict(
                    color='#EB0C0C', width=1),
              "Moving Average Short": dict(
                    color='grey', dash='dot'),
              "Moving Average Long": dict(
                    color='grey'),
              "Trading Signal": dict(
                    color='black', width=0.5),
              "All Issues Strategy": dict(
                    color="#0A9C02"),
              "G Strategy": dict(
                    color="#0A9C02"),
              "UNGC Strategy": dict(
                    color="#0A9C02"),
              "SASB Strategy": dict(
                    color="#0A9C02"),
              "UNGC Investment Score (ALL)": dict(
                    color="#EB0C0C", width=1),
              "SASB Investment Score (ALL)": dict(
                    color="#EB0C0C", width=1)}

tickers = companies_table["Tickers"]
inv_options = {"All Issues Strategy": ["ENVIRONMENT", "SOCIAL", "GOVERNANCE", "CROSS_CUTTING"],
               "G Strategy": ["GOVERNANCE"]}
ungcsasb_options = {"UNGC Strategy": ungcprinciples,
               "SASB Strategy": sasbissues}
truefalse = {'T': 1, 'F': 0}
incident_data[["ENVIRONMENT", "SOCIAL", "GOVERNANCE", "CROSS_CUTTING"]] = incident_data[["ENVIRONMENT", "SOCIAL", 
                                                                                         "GOVERNANCE", "CROSS_CUTTING"]].replace(truefalse)
incident_data[ungcprinciples] = incident_data[ungcprinciples].replace(truefalse).astype('bool')
incident_data[sasbissues] = incident_data[sasbissues].replace(truefalse).astype('bool')
In [12]:
# plot 1
ma_strategy, day_tuple = "12m/24m", (365, 2*365)
fig = make_subplots(rows=2, cols=1, start_cell="top-left", 
                    shared_xaxes=True, shared_yaxes=False,
                    vertical_spacing=0.03)        
visible = True
counter = 0
sharpe_ratio_annotations = []
dropdown_options = dict()
for issue in categ:
    incident_data[issue] = incident_data[issue].astype('bool')

for name, id_ in companies.items():
    counter += 1
    if counter > 100:
        break
    # fetch stock prices
    ticker = tickers[name]
    try:
        prices = pd.read_csv("ticker_data/{}_stockprice.csv".format(name), index_col=0)
        prices = prices['Adj Close']
        prices.index = pd.to_datetime(prices.index)
        prices.name = "Stock Price"
        daily_returns = prices.pct_change()
    except Exception:
        continue
    
    for strategy_name, inv_list in inv_options.items():
        series = get_raw_scores(id_, incident_data[incident_data[inv_list].any(axis=1)])
        series = max_decay(scale(series))
        series.name = strategy_name.replace('Strategy', 'Investment Score')
        ma_short = series.rolling(day_tuple[0], min_periods=0).mean()
        ma_short.name = "Moving Average Short"
        ma_long = series.rolling(day_tuple[1], min_periods=0).mean()
        ma_long.name = "Moving Average Long"
        score_step = getbinarysignal(ma_short, ma_long, series)
        score_step.name = "Trading Signal"
        cumreturns_stock = (daily_returns + 1).cumprod()
        
        # sharpe ratios
        sharpe_ratio_price = calculate_sharpe_ratio(daily_returns)
        adj_tradingsignal = adjust_trading_signal(score_step, date_range)
        strategy_daily_returns = daily_returns.where(adj_tradingsignal, 0)
        sharpe_ratio_invstrategy = calculate_sharpe_ratio(strategy_daily_returns)
        sharpe_ratio_annotations.append(sharpe_ratio_annotation(strategy_name.replace('Strategy', ''), 
                                                                sharpe_ratio_price, sharpe_ratio_invstrategy))
        dropdown_options[name + " - " + ma_strategy + " - " + strategy_name] = id_

        # investment strategy
        cumreturns = (strategy_daily_returns + 1)
        cumreturns = cumreturns.where(-adj_tradingsignal.diff().dropna().astype(bool), 
                                      cumreturns-0.0005) #transaction costs: 5bps
        cumreturns = cumreturns.cumprod()
        cumreturns.name = strategy_name
        traces = [cumreturns_stock, cumreturns, score_step, ma_long, ma_short, series]
        for trace in traces:
            if trace.name in ["Stock Price", "All Issues Strategy", "G Strategy"]:
                row = 1
                legendgrp = "Cumulative returns"
            else:
                row = 2
                legendgrp = "Signal"
            fig.add_trace(
                go.Scatter(x=trace.index, y=trace, name=trace.name, line=line_specs[trace.name], visible=visible,
                           legendgroup=legendgrp),
                row=row, col=1
            )
        visible = False

buttons = []
for i, name in enumerate(dropdown_options.keys()):
    buttons.append(button(i, name, sharpe_ratio_annotations[i], n_traces=6, n_dropdowns=len(sharpe_ratio_annotations)))
    
fig.update_layout(
    updatemenus=[
        dict(
            y=1.3,
            x=0,
            yanchor="top",
            xanchor="left",
            active=0,
            buttons=list(buttons)
        )
    ])
fig.update_yaxes(type="log", row=1, col=1, secondary_y=False)
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'),
                    title_text=None,
                    xaxis2_rangeslider_visible=True,
                    legend=dict(x=0, y=1.2),
                    legend_orientation="h",
                    template="plotly_white",
                    margin=dict(l=20, r=20, t=90, b=0),
                    height=550)
fig.update_traces(hovertemplate="<b>%{y}</b><br>" +
                                "Date: %{x|%Y-%m-%d}")
fig1 = fig
In [13]:
# plot 2
ma_strategy, day_tuple = "12m/24m", (365, 2*365)
fig = make_subplots(rows=2, cols=1, start_cell="top-left", 
                    shared_xaxes=True, shared_yaxes=False,
                    vertical_spacing=0.03)        
visible = True
counter = 0
sharpe_ratio_annotations = []
dropdown_options = dict()

for name, id_ in companies.items():
    counter += 1
    if counter > 100:
        break
    # fetch stock prices
    ticker = tickers[name]
    try:
        prices = pd.read_csv("ticker_data/{}_stockprice.csv".format(name), index_col=0)
        prices = prices['Adj Close']
        prices.index = pd.to_datetime(prices.index)
        prices.name = "Stock Price"
        daily_returns = prices.pct_change()
    except Exception:
        continue
    
    for strategy_name, inv_list in ungcsasb_options.items():
        series = get_raw_scores(id_, incident_data[incident_data[inv_list].any(axis=1)])
        series = max_decay(scale(series))
        series.name = strategy_name.replace("Strategy", "Investment Score (ALL)")
        ma_short = series.rolling(day_tuple[0], min_periods=0).mean()
        ma_short.name = "Moving Average Short"
        ma_long = series.rolling(day_tuple[1], min_periods=0).mean()
        ma_long.name = "Moving Average Long"
        score_step = getbinarysignal(ma_short, ma_long, series)
        score_step.name = "Trading Signal"
        cumreturns_stock = (daily_returns + 1).cumprod()
        
        # sharpe ratios
        sharpe_ratio_price = calculate_sharpe_ratio(daily_returns)
        adj_tradingsignal = adjust_trading_signal(score_step, date_range)
        strategy_daily_returns = daily_returns.where(adj_tradingsignal, 0)
        sharpe_ratio_invstrategy = calculate_sharpe_ratio(strategy_daily_returns)
        sharpe_ratio_annotations.append(sharpe_ratio_annotation(strategy_name.replace('Strategy', ''),
                                                                sharpe_ratio_price, sharpe_ratio_invstrategy))
        dropdown_options[name + " - " + ma_strategy + " - " + strategy_name] = id_

        # investment strategy
        cumreturns = (strategy_daily_returns + 1)
        cumreturns = cumreturns.where(-adj_tradingsignal.diff().dropna().astype(bool), 
                                      cumreturns-0.0005) #transaction costs: 5bps
        cumreturns = cumreturns.cumprod()
        cumreturns.name = strategy_name       
        traces = [cumreturns_stock, cumreturns, score_step, ma_long, ma_short, series]
        for trace in traces:
            if trace.name in ["Stock Price", "UNGC Strategy", "SASB Strategy"]:
                row = 1
                legendgrp = "Cumulative returns"
            else:
                row = 2
                legendgrp = "Signal"
            fig.add_trace(
                go.Scatter(x=trace.index, y=trace, name=trace.name, line=line_specs[trace.name], visible=visible,
                           legendgroup=legendgrp),
                row=row, col=1
            )
        visible = False

buttons = []
for i, name in enumerate(dropdown_options.keys()):
    buttons.append(button(i, name, sharpe_ratio_annotations[i], n_traces=6, n_dropdowns=len(sharpe_ratio_annotations)))
    
fig.update_layout(
    updatemenus=[
        dict(
            y=1.3,
            x=0,
            yanchor="top",
            xanchor="left",
            active=0,
            buttons=list(buttons)
        )
    ])
fig.update_yaxes(type="log", row=1, col=1, secondary_y=False)
fig.update_layout(font=dict(color='#231F20', family='UnitWeb, UnitOT, Verdana'),
                    title_text=None,
                    xaxis2_rangeslider_visible=True,
                    legend=dict(x=0, y=1.2),
                    legend_orientation="h",
                    template="plotly_white",
                    margin=dict(l=20, r=20, t=90, b=0),
                    height=550)
fig.update_traces(hovertemplate="<b>%{y}</b><br>" +
                                "Date: %{x|%Y-%m-%d}")
fig2 = fig

10.1. Displayed graphs

For a simple demonstration, the crossover of MA can be used as a very basic trading signal. The graphs below show the following:

  • Upper panel:
    • the stock price of the selected company [black]
    • the resulting Investment strategy when trading the signals of the lower panel [green]
  • Lower panel:
    • the incident scores of the selected company [green]
    • the Long-term versus Short-term MA [bold versus dotted in grey]
    • the trading signal to invest at the close of the next business day (transaction costs 5bps) [step function in grey]
In [14]:
fig1.show()
In [15]:
fig2.show()

Copyright, User Agreement, and other general information related to this website

Copyright 2026 RepRisk AG. All rights reserved. The information provided on this website is not intended to be relied upon as, nor to be a substitute for, specific financial, legal, or other professional advice. RepRisk expressly disclaims and will not accept any responsibility for loss or damages to any individual or legal entity with regard to any action or inaction related to or connected with any material on this website.

RepRisk AG retains copyright and all originators’ rights to the content on this website. The material on it may not be sold, reproduced, republished, distributed, or transmitted, in whole or in part, in any form or manner, without the express written consent of RepRisk AG. Any unauthorized use is prohibited. Use of this website constitutes your agreement not to sell, reproduce, republish, redistribute, or retransmit the contents, opinions, conclusions, or information contained on this website without first obtaining express permission from RepRisk AG.

The information herein (other than disclosed information relating to RepRisk AG) was obtained from various public sources. RepRisk AG does not guarantee its accuracy. In particular, materials prepared by RepRisk AG research analysts are based on public information. Facts and views presented in this material have not been reviewed by and may not reflect information known to professionals in other business areas of RepRisk AG.

This website may contain links to third-party websites. RepRisk AG is not responsible for the content of any third-party website, or any linked content contained in a third-party website. Content contained on such third-party websites is not part of this website and is not incorporated by reference into this website. The inclusion of a link on this website does not imply the endorsement by or any affiliation with RepRisk AG. Access to any third-party website is at your own risk, and you should always review the terms and privacy policies at third-party websites before submitting any personal information to them. RepRisk AG is not responsible for such terms and privacy policies and expressly disclaims any liability for them. 

Neither RepRisk AG nor any officer or employee of RepRisk AG accepts any liability whatsoever for any direct, indirect, or consequential damages or losses arising from any use of this website or its contents. 

The information on this site is provided on a discretionary basis. RepRisk AG makes no warranty that access to the site will be error free or continuously available. It is possible that defects and errors occur. RepRisk AG reserves the right to discontinue operating this site or to restrict access at any time. 

In no event, including (without limitation) negligence, will RepRisk AG be liable for any loss or damage of any kind, including (without limitation) any direct, indirect, or consequential damages, even if expressly advised of the possibility of such damages, arising out of or in connection with the access of, use of or link to other sites from this website. 

Should the use of this website result in the creation of any relationship under the law between the user and RepRisk AG, any such relationship and any disputes arising in connection therewith will be subject to Swiss law, and the exclusive forum for any disputes will be Zurich, Switzerland. 

This disclaimer is modified from time to time. Make sure that you check these terms every time you access this website. 

© RepRisk AG

In [16]:
%%javascript
document.title='RepRisk | Investment Score configurator'

window.onload=function(){
    var mobile = (/iphone|ipod|android|blackberry|mini|windows\sce|palm/i.test(navigator.userAgent.toLowerCase()));
    if (mobile) {
        alert("For an improved user experience, we recommend that you switch to a desktop device.");              
    } else {

    }
  }