Source code for factor_agent.exported

"""Accepted factors as quantlab factors: a ``FactorKunQuant`` whose outputs are recorded graphs.

Each output is a candidate's graph ranked across the whole market (KunQuant
``Rank``), the values the Evaluator scores (``factor_agent.scoring``), built by the
same ``ranked_function``. ``Acceptance.export`` writes one module per
factor, a subclass of ``GraphFactor`` that fixes ``GRAPHS`` and the
``CLASSES`` the campaign's vocabulary built them from;
``Acceptance.validate`` uses ``GraphFactor`` itself, with both in
``config.kwargs``.

A factor of an intraday family is held in its stages
(``factor_agent.gate.stages``, ``staged``): its graph is the daily stage,
reading each session column as an input, and ``SESSIONS`` maps each session
column to its intraday graph, computed from the intraday dataset of
``INTRADAY`` by ``factor_agent.session.SessionColumns``, as the Evaluator
computes it.
"""

from collections.abc import Mapping

from KunQuant.Op import OpBase, Output, Rank
from KunQuant.ops import AddConst, MulConst
from KunQuant.Stage import Function
from quantlab.core.component import rebuild

from factor_agent import derived, period_lag
from factor_agent.gate import stages
from factor_agent.gate.graph import Graph
from factor_agent.gate.vocabulary import Vocabulary, resolve_classes
from factor_agent.session import SessionColumns


[docs] def ranked_function( graphs: Mapping[str, Graph], presence: Mapping[str, str] | None = None, directions: Mapping[str, int] | None = None, ) -> Function: """One KunQuant function with an output per graph, named after it, ranked across the market. A graph whose ``directions`` entry is -1 (a higher value went with a lower return) is output as one minus its rank, so every output reads the same way: a higher value, a higher return. KunQuant's ``Rank`` leaves a missing value missing and ranks the finite values among themselves. ``presence`` maps a graph's name to the name of a second output, 0 where the graph itself has a value and NaN where it has none, read raw (never neutralised, so a symbol without exposures still counts) and before any fill: it is computed from ``Graph.without_fills``, so a missing value a ``SetInfOrNanToValue`` fills stays missing in it. """ inputs: dict[str, OpBase] = {} ops: list[OpBase] = [] for name, graph in graphs.items(): output, new = graph.build(inputs) ranked = Rank(output) ops += [*new, ranked] if directions and directions.get(name) == -1: flipped = MulConst(ranked, -1.0) ranked = AddConst(flipped, 1.0) ops += [flipped, ranked] ops.append(Output(ranked, name)) if presence and name in presence: unfilled = graph.without_fills() if unfilled is not graph: output, new = unfilled.build(inputs) ops += new zero = MulConst(output, 0.0) ops += [zero, Output(zero, presence[name])] return Function(ops)
[docs] class GraphFactor(derived.DerivedColumnsFactor): """Recorded graphs, each ranked across the market, as one factor. The graphs, name to ``Graph.to_json()``, are the class's ``GRAPHS`` (an exported factor) or, when it has none, ``config.kwargs["graphs"]``; the class path of each operator they record, ``CLASSES`` or ``config.kwargs["classes"]`` (``Vocabulary.class_paths``); how each lag column they read is computed, ``PERIOD_LAGS`` or ``config.kwargs["period_lags"]`` (``period_lags``), none when neither is set; each output's direction, ``DIRECTIONS`` or ``config.kwargs["directions"]`` (+1 when unset), turns it so a higher value means a higher return. A graph of an intraday family is its daily stage (``staged``): the intraday graph of each session column it reads is in ``SESSIONS`` or ``config.kwargs["sessions"]``, and the intraday dataset they run on, as a ``get_config()`` dict, in ``INTRADAY`` or ``config.kwargs["intraday"]``. ``config.data_columns`` must hold every dataset column the graphs and their lags read (``data_columns``). """ #: Output name -> ``Graph.to_json()``; set by an exported subclass. GRAPHS: dict = {} #: Operator name -> class path, of every operator ``GRAPHS`` records; set by an exported subclass. CLASSES: dict = {} #: Lag column -> how it is computed, of every lag column ``GRAPHS`` reads; set by an exported subclass. PERIOD_LAGS: dict = {} #: Output name -> its direction, +1 or -1; set by an exported subclass. DIRECTIONS: dict = {} #: Session column -> its intraday graph, of every session column ``GRAPHS`` reads; set by an exported subclass. SESSIONS: dict = {} #: The intraday dataset ``SESSIONS`` runs on, as a ``get_config()`` dict; set by an exported subclass. INTRADAY: dict = {} def _recorded(self) -> dict: return type(self).GRAPHS or dict((self.config.kwargs or {})["graphs"]) def _class_paths(self) -> dict: return type(self).CLASSES or dict((self.config.kwargs or {})["classes"]) def _classes(self) -> dict: return resolve_classes(self._class_paths()) def _period_lags(self) -> dict: return type(self).PERIOD_LAGS or dict((self.config.kwargs or {}).get("period_lags") or {}) def _sessions(self) -> dict: return type(self).SESSIONS or dict((self.config.kwargs or {}).get("sessions") or {}) def _providers(self) -> list[derived.Provider]: providers = period_lag.providers(self._period_lags()) sessions = self._sessions() if sessions: intraday = type(self).INTRADAY or (self.config.kwargs or {})["intraday"] providers.append(SessionColumns(rebuild(intraday), sessions, self._class_paths(), self.config.njobs)) return providers def _directions(self) -> dict: return type(self).DIRECTIONS or dict((self.config.kwargs or {}).get("directions") or {}) def _get_factor_names(self) -> tuple[str, ...]: return tuple(self._recorded()) def _get_factor_func(self) -> Function: classes = self._classes() graphs = {name: Graph.from_json(graph, classes) for name, graph in self._recorded().items()} return ranked_function(graphs, directions=self._directions())
[docs] @staticmethod def data_columns( graphs: Mapping[str, list], period_lags: Mapping[str, Mapping] | None = None, sessions: Mapping[str, list] | None = None, ) -> tuple[str, ...]: """The daily dataset columns the recorded graphs read, with the columns and keys of their ``period_lags``, sorted. The session columns of ``sessions`` are filled from the intraday dataset, not read. """ return tuple(sorted(set(derived.data_columns(_inputs(graphs), period_lag.providers(period_lags or {}))) - set(sessions or {})))
[docs] @staticmethod def staged(graphs: Mapping[str, list], vocabulary: Vocabulary) -> tuple[dict, dict]: """Recorded graphs in their stages: each one's daily graph, by name, and every session column's intraday graph. A graph of no intraday family is its own daily graph and reads no session column. Both are ``Graph.to_json()``. Raises ------ ValueError If a graph breaks a stage rule (``stages.split``). """ daily, sessions = {}, {} for name, recorded in graphs.items(): split = stages.split(Graph.from_json(recorded, vocabulary.classes), vocabulary) if split.reasons: raise ValueError(f"{name}: the graph breaks the stage rules: {'; '.join(split.reasons)}") daily[name] = split.daily.to_json() sessions |= {column: graph.to_json() for column, graph in split.intraday.items()} return daily, sessions
[docs] @staticmethod def period_lags(graphs: Mapping[str, list], lag_columns: Mapping[str, Mapping]) -> dict: """The entries of ``lag_columns`` (``Vocabulary.lag_columns``) the recorded graphs read.""" return period_lag.read(_inputs(graphs), lag_columns)
def _inputs(graphs: Mapping[str, list]) -> set[str]: """The names of the ``Input`` nodes of recorded graphs.""" return {dict(node[1])["name"] for graph in graphs.values() for node in graph if node[0] == "Input"}