Factor research: from rows to tables
The factor area turns a prepared panel into IC studies, quantile buckets, tears, artifacts and export tables. This guide follows that path and points out the places where a configuration choice changes the result.
The input panel
prepared_factor_data is the only accepted input:
struct prepared_factor_data {
std::vector<factor_row> rows; // retained observations
std::vector<std::string> forward_labels; // labels aligned with row returns
factor_loss_report loss_report; // cleaning-loss accounting
};
Each factor_row carries a date, an asset, a finite factor value, a one-based
quantile label supplied by the caller, an optional group label, and a vector of
forward returns aligned by position with forward_labels. That alignment is
the contract: the label order and the per-row vector order must describe the same
columns.
Analysis configuration
fincore::factor_analysis_config config;
config.periods = {"1D", "5D"}; // must name prepared labels
config.turnover_periods = {1, 5}; // positive, unique lags
config.by_group = true; // needs a group column
config.include_portfolio_inputs = false;
const auto model = fincore::analyze_factor(prepared, config);
Field |
Default |
What it changes |
|---|---|---|
|
|
Demeans weights and returns per date; |
|
|
Demeans within date and group; needs groups |
|
|
Equal-weights the positive and negative sides |
|
|
Adds per-group views; needs a group column |
|
empty (all) |
Narrows the analysed forward-return labels |
|
|
Positive, unique lag values |
|
|
Monthly |
|
|
Builds portfolio inputs for the first selected period |
|
|
Non-empty label recorded in the fingerprint |
analyze_factor raises factor_data_error when the panel has no rows or labels,
a row’s return count does not match the label count, turnover_periods or
time_aggregation is empty, duplicated or invalid, the benchmark period is
empty, a requested period is not a prepared whole-day label, or by_group is set
without a group column.
What the model contains
Aggregate views such as
mean_returns_by_quantileandmean_returns_by_date, plus IC and turnover tables.Per-group views when
by_groupis set; those tables were computed from the full universe before group rows were selected, so demeaning can span groups.A fingerprint (
result_fingerprint) that hashes the inputs, not the results, so two runs with the same inputs agree even before comparing numbers.
Two quantile tables exist and they are flattened differently. The aggregate one
(quantile_return_table) is quantiles × labels: quantiles holds the row
keys and labels the column keys. The per-date one
(quantile_date_return_table) adds a leading row axis, so it is
date × quantiles × labels, and one date’s block is quantiles × labels wide.
Read the field comment before indexing by hand; both carry groups only when the
output is grouped.
Tears and artifacts
build_factor_tear_charts(model) packages the model into chart-ready series:
information coefficient, mean return by quantile and period, top-minus-bottom
spread, turnover, rank autocorrelation, plus optional event and group snapshots.
build_cumulative_returns_by_quantile, build_events_distribution,
build_ic_qq, build_quantile_returns_violin and build_monthly_ic_heatmap
produce individual charts from the same tables.
factor/artifacts.hpp provides the export path. Its tables are an ordered
contract: column order is part of the digest, so a column inserted in the middle
is a contract change even when the values are identical.
Point-in-time joins
Point-in-time helpers join a signal panel to a reference panel using three timestamps per reference row: when the value became effective, when it became known, and the as-of date being evaluated. A reference row is eligible only when it was effective and known by the as-of date; ties resolve in the order the header documents. Universe filters — for example dropping assets that are not in the index on that date — are applied on top of that cut, which is why a case can legitimately end up with fewer cross-sections than input dates.
Exports and comparison
factor/research.hpp writes the research tables used by the Alpha003 benchmark.
The comparison is streamed and runs outside every timed interval; no join,
dropna, aggregation or tolerance expansion is applied to hide missing
observations. A mismatch is reported with the table, row and column that differ,
not as a single failing boolean.
Two practical rules follow:
Changing a header, a label or a column order changes the digest, so export changes are visible in the paired benchmark even when the numbers are equal.
Pinned reference files under
tests/fixturesandtests/dataare evidence. Treat their text as data, not as prose to reword.
A minimal study
const auto model = fincore::analyze_factor(prepared, config);
const auto tears = fincore::build_factor_tear_charts(model);
const auto& table = model.mean_returns_by_quantile;
// Row keys are quantiles, column keys are labels: stride the row-major buffer.
for (std::size_t q = 0; q < table.quantiles.size(); ++q) {
for (std::size_t l = 0; l < table.labels.size(); ++l) {
const double mean = table.means[q * table.labels.size() + l];
std::printf("Q%d %s %.5f
", table.quantiles[q],
table.labels[l].c_str(), mean);
}
}
The aggregate table’s label order follows the request, so print
mean_returns_by_quantile.labels alongside any table you save; a table without
its label order is ambiguous once it leaves the process.