trader.reporting.concentration module

Sector concentration and return-correlation measurement (issue #65).

docs/ideas.md item 7 named this “the most under-measured risk here”: theme-driven discovery structurally concentrates a book into a handful of correlated macro bets wearing different tickers, and neither max_position_pct nor max_total_exposure_pct notices that the names move together. This module is a measurement only — it computes numbers for an existing report to print. It does not gate an order, is never called from execution/ or a strategy, and imports nothing from brokers/ or execution/ itself.

Two independent measurements, matching the issue’s two asks:

  • sector_exposure — held positions’ market value, grouped by sector, as a fraction of the book. A symbol whose sector lookup fails or is unclassified (an ETF, mainly) is grouped under UNKNOWN_SECTOR, an explicit and visible bucket added into the total the same way a real sector is — never silently dropped, and never folded into a real sector as if it were known to belong there.

  • compute_correlations — pairwise Pearson correlation of daily returns across held positions, computed from the existing bar cache (no new data source). A symbol with too little history to build a return series (a fresh listing, a data outage) is left out of every pair rather than treated as uncorrelated with everything else — “not computed” is not the same claim as “zero risk”.

A third helper, sector_distribution_by_count, answers the “discovered” half of the issue’s first ask: a discovered symbol carries no market value until it becomes a position, so weighting it by dollars is meaningless — counting it once each is the only honest way to show whether the candidate pool itself is concentrated before any of it is bought.

trader.reporting.concentration.DEFAULT_CORRELATION_LOOKBACK_DAYS = 90

Default lookback for the correlation window. 90 calendar days is roughly 60 trading sessions — enough for a Pearson correlation to mean something without reaching so far back that a recently discovered, thinly-traded name has no history at all inside the window.

trader.reporting.concentration.UNKNOWN_SECTOR = 'Unknown'

The explicit bucket for a symbol whose sector could not be determined — no coverage (an ETF), a lookup failure, or an unclassified new listing. Never blank, and never folded into a real sector.

class trader.reporting.concentration.PortfolioCorrelation(pairwise, average_pairwise)[source]

Bases: object

Every computable pair, and their average.

average_pairwise is None — never 0.0 — when fewer than two symbols had enough return history to compute even one pair: “no correlation data” and “measured, and it’s zero” are different claims, and this module never lets the first read as the second.

Parameters:
pairwise: tuple[SymbolCorrelation, ...]
average_pairwise: float | None
class trader.reporting.concentration.SectorCount(sector, count, pct_of_total, symbols)[source]

Bases: object

One sector’s (or UNKNOWN_SECTOR’s) share of a symbol list, by count.

Parameters:
  • sector (str)

  • count (int)

  • pct_of_total (Decimal)

  • symbols (tuple[str, ...])

sector: str
count: int
pct_of_total: Decimal
symbols: tuple[str, ...]
class trader.reporting.concentration.SectorExposure(sector, market_value, pct_of_total, symbols)[source]

Bases: object

One sector’s (or UNKNOWN_SECTOR’s) share of held market value.

Parameters:
  • sector (str)

  • market_value (Decimal)

  • pct_of_total (Decimal)

  • symbols (tuple[str, ...])

sector: str
market_value: Decimal
pct_of_total: Decimal
symbols: tuple[str, ...]
class trader.reporting.concentration.SymbolCorrelation(symbol_a, symbol_b, correlation)[source]

Bases: object

Pearson correlation of daily returns between two held symbols.

Parameters:
  • symbol_a (str)

  • symbol_b (str)

  • correlation (float)

symbol_a: str
symbol_b: str
correlation: float
trader.reporting.concentration.compute_correlations(positions, bar_cache, *, now, lookback_days=90)[source]

Pairwise correlation across held positions’ daily returns.

Bars come from the existing BarCache — no new data source, per the issue’s own constraint. A symbol whose bar fetch fails, or that has too few bars in the window (_MIN_RETURNS_FOR_CORRELATION), is left out of every pair rather than defaulting to any particular correlation value.

Parameters:
  • positions (Sequence[Position])

  • bar_cache (BarCache)

  • now (datetime)

  • lookback_days (int)

Return type:

PortfolioCorrelation

trader.reporting.concentration.sector_distribution_by_count(symbols, sector_provider)[source]

A symbol list’s sector breakdown, weighted one-per-symbol.

For a set of symbols that carry no market value yet — discovered candidates that are not (or not all) held positions — counting is the only weighting that makes sense; see the module docstring. Duplicate symbols in symbols are deduplicated first, so a candidate discovered on more than one theme is not counted twice.

Parameters:
Return type:

list[SectorCount]

trader.reporting.concentration.sector_exposure(positions, sector_provider)[source]

Held positions’ market value, grouped by sector, richest first.

Weighted by market_value — this answers “how much of the book sits in one sector”, not “how many names”. A sector lookup failure for one symbol costs only that symbol (same isolation discipline as discovery and the analyst-consensus fetch): it is grouped under UNKNOWN_SECTOR and logged, never allowed to abort the rest of the book’s grouping.

Parameters:
Return type:

list[SectorExposure]