Price ingestion
The ingestion layer
PriceIngestion assembles raw price series into the carrier the conversion reads. It runs once on the whole panel and is deliberately not a Pipeline step: unification of the two absent-price conventions, the factor and benchmark join, and the frequency collapse each move or renumber the observations, folds are cut once on the carrier's clock, and a hyperparameter that changes the test set cannot be scored against one that does not. Running outside the Pipeline is also what lets the Span Rule read the whole panel, which a step — seeing only a window — cannot.
The carrier it emits holds the Listing Span in its span field. PricesToReturns projects that onto the returns clock and hands the ReturnsResult an AssetPanel stating the universe — always, a gapless panel included, so pnl === nothing on a returns carrier means one thing only: the carrier was not built by the layer. The gapless case costs nothing to say: both masks become a PortfolioOptimisers.AllTrueMask, which stores no cell.
The layer spells an absent price NaN, which is the library's one spelling for absence, and the conversion carries it into the returns rather than deleting the observation or the asset that holds one. A carrier the layer did not build states no span, and then no universe: the conversion does not guess one from the window, because a delisting straddling the window end reads there as an asset that was never listed. See docs/adr/0129-the-ingestion-layer-seams-at-the-listing-span-and-the-clock-draws-the-pipeline-boundary.md and docs/adr/0132-the-layer-emits-one-carrier-and-alignment-splits-into-a-fixed-axis-and-a-provenance-check.md.
The asset table states the clock. Under the default join_method = :left the factor, benchmark and implied-volatility series are aligned to the asset clock and padded NaN where they are silent, so timestamp(pr.X) == timestamp(X) unless collapse_args is non-empty; :outer and :inner stay reachable. What the join padded is named — which table, how many observations, which columns — warning by default and refusing under strict. The value type the carrier holds is derived from the series rather than named: a Float32 panel stays Float32, Float32 beside Float64 joins in Float64, an integer panel takes the floating-point type the return arithmetic gives it, and a type that cannot spell an absence is refused by name at the first gap it would have to spell. An absent implied volatility is carried like an absent price, and ImpliedVolatility narrows its Coverage Universe to the columns whose implied volatilities are complete. See docs/adr/0135-the-asset-table-states-the-clock-and-the-layer-carries-every-absence-and-names-it.md.
Types
PortfolioOptimisers.PriceIngestion — Type
struct PriceIngestion{__T_join_method, __T_collapse_args, __T_span, __T_strict} <: AbstractEstimatorEstimator assembling raw price series into the span-carrying price carrier the ingestion layer converts.
PriceIngestion runs once on the whole panel, and it is deliberately not a Pipeline step. The rule that puts it outside is the observation clock: unification, the factor and benchmark join, and the frequency collapse each move or renumber the observations, folds are cut once on the carrier's clock, and a hyperparameter that changes the test set cannot be scored against one that does not. A step that only touches values leaves the clock alone and stays inside.
Running outside the Pipeline is also what lets the Span Rule read the whole panel. A step only ever sees a window, and a window-local span reads a delisting straddling the window end as an asset that was never listed. What licenses the panel-wide read where a split otherwise forbids work before it is that a listing calendar is a fact about the instruments, not an estimate from returns.
The carrier it emits holds the derived Listing Span in its span field, so the conversion can project it onto the returns clock and hand the returns carrier an AssetPanel stating the universe. A caller holding their own listing calendar passes it as span and replaces the Span Rule's answer outright.
The asset table states the clock. It states the universe, so it states the clock, and the factor, benchmark and implied-volatility series are aligned to it under the default join_method = :left, padded where they are silent. The layer names what it padded — which table, how many observations, which columns — warning by default and refusing under strict, because a carried absence is invisible where a refused one was not and those axes carry no universe that would name it later. Each field states something about the sources the layer cannot derive: which clock is authoritative, what frequency an analysis wants, which listing calendar the caller holds, and whether a padded covariate is acceptable.
Fields
join_method: How the factor and benchmark series are joined onto the asset clock, asTimeSeries.mergetakes it.:leftkeeps the asset clock and pads the others where they are silent;:outertakes the union of the clocks, padding every table;:innertakes their intersection, padding none.
collapse_args: Arguments for collapsing the joined series to a lower frequency, asTimeSeries.collapsetakes them. Empty leaves the clock alone.
span: Optional listing statement of the caller's own,price observations × assets, replacing the Span Rule's answer outright. A listing calendar, or a constituency that leaves and rejoins, is anyAbstractMatrix{Bool}.nothingderives the span from the gaps.
strict: Whether a padded observation refuses the ingestion.falsenames what the join and the alignment padded in a warning;trueraises anArgumentErrorcarrying the same report.
Constructors
PriceIngestion(; join_method::Symbol = :left, collapse_args::Tuple = (), span::Option{<:AbstractMatrix{Bool}} = nothing, strict::Bool = false,) -> PriceIngestionKeywords correspond to the struct's fields.
Examples
julia> X = TimeArray(Date(2020, 1, 1):Day(1):Date(2020, 1, 3), [NaN 101.0; 102.0 103.0; 104.0 105.0], ["A", "B"]);julia> pr = price_ingestion(PriceIngestion(), X);julia> pr.spanListingSpan(3 × 2)julia> Matrix(pr.span)3×2 Matrix{Bool}: 0 1 1 1 1 1Related
Functions
PortfolioOptimisers.price_ingestion — Function
price_ingestion(est::PriceIngestion, X::TimeSeries.TimeArray;
F::Option{<:TimeSeries.TimeArray} = nothing,
B::Option{<:TimeSeries.TimeArray} = nothing,
iv::Option{<:TimeSeries.TimeArray} = nothing,
ivpa::Option{<:Num_VecNum} = nothing,
pnl::Option{<:AssetPanel} = nothing) -> PricesResult
price_ingestion(est::PriceIngestion, pr::PricesResult) -> PricesResultAssemble raw price series into the span-carrying price carrier.
The three clock-moving steps run here rather than in a Pipeline, and the Span Rule reads the whole panel once they have. What the caller gets back is an ordinary PricesResult whose span field states which assets are listed at each observation, ready for PricesToReturns to project onto the returns clock.
The layer spells an absent price NaN, which is the library's one spelling for absence, and the conversion carries it into the returns rather than deleting the observation or the asset that holds one. An absence is carried on every axis the layer touches: a factor, benchmark or implied-volatility series silent at an observation of the emitted clock is padded there, and the padding is named — warned by default, refused under strict — because those axes carry no universe that would name it later.
The value type the carrier holds is derived from the series rather than named. The asset, factor and benchmark types are promoted to one, because the join lays them in one table, and that type is widened only as the return arithmetic would widen it: a Float32 panel stays Float32, Float32 beside Float64 joins in Float64, and an integer panel takes the floating-point type that represents it. A type that cannot spell an absence is refused by name at the first gap or padded observation it would have to spell.
Algorithm
- Check that the asset, factor and benchmark series can still be named after the join with
assert_distinct_series_names. The join renames a name two tables share, so a block would otherwise be taken apart into another block's column. - Derive the unification target: promote the value types the three price tables contribute through
series_value_type, and widen the result withabsence_type. Unify the absent-price convention of every series in that type withunify_gaps, so that a gap means one thing from here on. - Join the factor and the benchmark series onto the asset clock under
join_method. A left join keeps the asset clock and pads the others where they are silent; an outer join adds the rows one series has and another does not, padding every table; an inner join keeps the rows every table has. A join that pads asksassert_pad_spellablefirst, which refuses by name a type that cannot spell the pad. - Write the padding report for the asset, factor and benchmark tables against the joined clock with
padding_report_line, before the collapse renumbers it. - Collapse the joined series to a lower frequency when
collapse_argsis non-empty, which renumbers every observation. - Split the joined series back into their asset, factor and benchmark blocks, all now on one clock.
- Align the implied volatilities to that clock with
align_series, in the typeabsence_typederives from their own, padding the observations they are silent at; write their report line; and carryivpathrough. An implied volatility is a volatility rather than a price, so it is carried, never converted. - Name what was padded with
assert_join_padding, which warns, or refuses understrict. - Put a caller's
AssetPanelon the emitted clock withproject_panel_clock. A caller states a Panel Field on the clock of the table they hold, and the collapse is the only step here that renumbers it, so this is where it is projected: the aggregated period takes the values of the row at its representative timestamp, which is last-observation semantics and matchesLastObservation. A static panel has no observation axis and is carried through untouched. - Read the Listing Span off the asset block with
listing_span, unless the caller declared one, in which case theirs is taken outright. - Return the
PricesResultcarrying all of it.
The emitted clock is the asset table's under the default join, so timestamp(pr.X) == timestamp(X) unless collapse_args is non-empty, and a caller declaring their own listing calendar can size it against the table they hold.
Arguments
est: ThePriceIngestionestimator.X: Asset prices,observations × assets.F: Optional factor prices.B: Optional benchmark prices, one column or one per asset.iv: Optional implied volatilities, one column per asset, on any clock: aligned to the emitted one and paddedNaNwhere silent.ivpa: Optional implied volatility adjustment.pnl: OptionalAssetPanelof Panel Fields the caller already holds. Its Panel Fields are kept; its masks are not read, because a time-varying panel carries anamskby construction, so the field cannot carry a declaration. A listing statement isspan.pr: APricesResult, for the second form, whose series are re-ingested. A span the carrier states is kept unlessest.spanoverrides it; the Span Rule runs only where neither states one.
Validation
!isempty(X). Raises anIsEmptyError.- The asset, factor and benchmark column names are pairwise disjoint, and none of them is
timestamp. Raises aConflictingArgumentErrornaming the offending columns. - A declared
spanissize(values(X))after the join and the collapse. Raises aDimensionMismatch. - The value type carries an absence, wherever one must be spelled. Raises a
DomainErrornaming the type, fromabsent_value. - Nothing was padded, under
strict. Raises anArgumentErrorcarrying the padding report, fromassert_join_padding; a warning otherwise.
Returns
pr::PricesResult: The price carrier, holding the Listing Span in itsspanfield.
Examples
julia> X = TimeArray(Date(2020, 1, 1):Day(1):Date(2020, 1, 4), [100.0 missing; 102.0 103.0; 101.0 104.0; 103.0 102.0], ["A", "B"]);julia> pr = price_ingestion(PriceIngestion(), X);julia> values(pr.X)[1, :]2-element Vector{Float64}: 100.0 NaNjulia> Matrix(pr.span)4×2 Matrix{Bool}: 1 0 1 1 1 1 1 1Related