Skip to content

AddEquity resolution becomes order-dependent when an equity permtick such as LIFE.1 is added before LIFE #9781

Description

@LinFei83

Adding multiple equities should resolve each requested ticker to the correct Symbol / SecurityIdentifier regardless of the order in which AddEquity() is called.

For example, given three valid equity map files representing three different securities:

life.1.csv
19980102,life,Q
20140203,life,Q
atyr.csv
20190701,life,Q
20240604,life,Q
20501231,atyr,Q
life.csv
20260129,life,Q
20501231,life,Q

the following securities should resolve to three different SIDs:

LIFE.1  -> old Life Technologies security
ATYR    -> aTyr Pharma security, historically LIFE -> ATYR
LIFE    -> current LIFE security

The result should not depend on the order of the calls:

self.AddEquity("LIFE.1", Resolution.Daily)
self.AddEquity("ATYR", Resolution.Daily)
self.AddEquity("LIFE", Resolution.Daily)

versus:

self.AddEquity("LIFE", Resolution.Daily)
self.AddEquity("ATYR", Resolution.Daily)
self.AddEquity("LIFE.1", Resolution.Daily)

Both call orders should resolve to the same three security identities.


Actual Behavior

The result currently depends on the order of AddEquity() calls.

When the securities are added in this order:

life1 = self.AddEquity("LIFE.1", Resolution.Daily)
atyr = self.AddEquity("ATYR", Resolution.Daily)
life = self.AddEquity("LIFE", Resolution.Daily)

LEAN resolves LIFE.1 and LIFE to the same SID:

LIFE.1 internal ID: LIFE R735QTJ8XC9X
ATYR   internal ID: LIFE X5SL0R29JU5H
LIFE   internal ID: LIFE R735QTJ8XC9X

As a result:

SID(LIFE.1) == SID(LIFE)

even though life.1.csv and life.csv represent different equity securities with non-overlapping listing periods.

If the order is changed so that LIFE is added before LIFE.1:

life = self.AddEquity("LIFE", Resolution.Daily)
atyr = self.AddEquity("ATYR", Resolution.Daily)
life1 = self.AddEquity("LIFE.1", Resolution.Daily)

the three securities resolve correctly to different SIDs.

This makes equity identity resolution dependent on the state of SymbolCache and on the order in which securities are registered.


Potential Solution

The behavior appears to be related to the fallback logic in SymbolCache.TryGetSymbol().

When an exact ticker is not present in the cache, SymbolCache performs a prefix search using:

var search = $"{ticker}.";

and if exactly one matching cached key exists, it returns that Symbol.

This fallback is documented in the source as a backwards-compatibility mechanism for custom data symbols, for example:

BTC -> BTC.Bitcoin

However, the same logic also applies to equities.

Therefore, after:

LIFE.1

has been added to the cache, a lookup for:

LIFE

can find the single LIFE.* match and return the LIFE.1 equity before normal equity symbol generation / map-file resolution occurs.

One possible solution would be to restrict this dotted-symbol fallback to the security types for which it was intended, such as SecurityType.Base.

For example, conceptually:

var match = Symbols
    .Where(kvp =>
        kvp.Key.StartsWith(
            search,
            StringComparison.InvariantCultureIgnoreCase)
        && kvp.Value is not null
        && kvp.Value.SecurityType == SecurityType.Base)
    .ToList();

Another possible solution would be for AddSecurity / AddEquity to use an exact-cache lookup before creating an equity Symbol, instead of using the broader SymbolCache.TryGetSymbol() fallback behavior.

For equities, a cached permtick such as:

LIFE.1

should not implicitly satisfy an exact request for:

LIFE

unless the normal equity map-file / SID resolution determines that they represent the same security.

A regression test could also verify that the following two call orders produce identical SIDs:

LIFE.1 -> ATYR -> LIFE

and:

LIFE -> ATYR -> LIFE.1

Reproducing the Problem

The issue can be reproduced using three equity map files.

Create:

Data/equity/usa/map_files/life.1.csv

with:

19980102,life,Q
20140203,life,Q

Create:

Data/equity/usa/map_files/atyr.csv

with:

20190701,life,Q
20240604,life,Q
20501231,atyr,Q

Create:

Data/equity/usa/map_files/life.csv

with:

20260129,life,Q
20501231,life,Q

Then run:

from AlgorithmImports import *


class LifeReuseAlgorithm(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2024, 5, 20)
        self.SetEndDate(2026, 3, 2)
        self.SetCash(100000)

        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw

        life1 = self.AddEquity("LIFE.1", Resolution.Daily)
        atyr = self.AddEquity("ATYR", Resolution.Daily)
        life = self.AddEquity("LIFE", Resolution.Daily)

        self.Log("LIFE.1 internal ID: {}".format(life1.Symbol.ID))
        self.Log("ATYR internal ID: {}".format(atyr.Symbol.ID))
        self.Log("LIFE internal ID: {}".format(life.Symbol.ID))

        if life1.Symbol.ID == life.Symbol.ID:
            raise Exception(
                "LIFE.1 and LIFE incorrectly resolved to the same SID: {}".format(
                    life.Symbol.ID
                )
            )

Observed output:

LIFE.1 internal ID: LIFE R735QTJ8XC9X
ATYR   internal ID: LIFE X5SL0R29JU5H
LIFE   internal ID: LIFE R735QTJ8XC9X

Changing only the registration order to:

life = self.AddEquity("LIFE", Resolution.Daily)
atyr = self.AddEquity("ATYR", Resolution.Daily)
life1 = self.AddEquity("LIFE.1", Resolution.Daily)

avoids the collision.

The expected behavior is that symbol identity should be independent of registration order.

A smaller unit test may also be possible directly against SymbolCache:

// Conceptual regression test

SymbolCache.Clear();

// Register an equity under a dotted permtick such as LIFE.1.
SymbolCache.Set("LIFE.1", oldLifeSymbol);

// An exact request for the separate equity ticker LIFE should not
// resolve to LIFE.1 through the custom-data dotted-symbol fallback.
Assert.IsFalse(SymbolCache.TryGetSymbol("LIFE", out var symbol));

The exact assertion may need to be adapted to the intended SymbolCache API behavior, but the important condition is that an equity key such as LIFE.1 should not shadow the separate equity ticker LIFE.


System Information

LEAN ALGORITHMIC TRADING ENGINE v2.5.0.0
Mode: DEBUG
Python: 3.11.14
Execution: LEAN CLI / local backtesting environment

OS:

[Please add the host OS / Docker image information if available]

I can reproduce the problem consistently with the call order described above.


Checklist

  • I have completely filled out this template
  • I have confirmed that this issue exists on the current master branch
  • I have confirmed that this is not a duplicate issue by searching [issues](https://github.com/QuantConnect/Lean/issues)
  • I have provided detailed steps to reproduce the issue

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions