Research agent
quantred/research/ closes the loop: an offline, self-pacing agent that, given a
goal, runs the hypothesis → validate → keep/discard cycle on its own and hands a
human a shortlist of vetted, provenance-stamped candidate configs. It reuses the
same quantred/services/ core as the MCP server, so there is one code path.
The agent's genuine edge over the GP optimizer is creativity — hypotheses and (behind a flag) new strategy code. The deterministic pipeline supplies the skepticism: walk-forward scores out-of-sample, the promotion gates accept or reject, and the sacred holdout is the final exam.
The loop (agent.py)
goal ─▶ propose (Proposer) ─▶ hygiene gate ─▶ validate OUT-OF-SAMPLE (walk-forward)
─▶ keep iff "promotable" AND beats incumbent OOS (drawdown-guarded)
─▶ loop until trial budget / token budget / K dry rounds
─▶ score the shortlist ONCE on the sacred holdout ─▶ save configs for a human
Non-negotiable guardrails (enforced in code, not by prompt)
- OOS-only fitness. Selection uses the walk-forward OOS aggregate; in-sample metrics are recorded but never the criterion.
- Multiple-testing correction.
n_trialsaccumulates across the whole session (vian_trials_offset) and feeds the deflated Sharpe — the more the agent tries, the higher the bar each new config must clear. The count is per session: it resets when the process exits, so trials from earlier sessions do not raise the bar for this one at gate time. Every round is also dual-written into the trial store, so the campaign-wide count is queryable by hand (trials list) even though it isn't wired into the gate itself — see the open item. Read a deflated Sharpe as a lower bound on the deflation warranted. - Sacred holdout. Reserved up front, never passed to any search, scored once at the end on the final shortlist.
- Budgets + dryness stop. Hard caps on trials and tokens, plus a "K rounds with no OOS improvement" stop, so the loop can't chase noise forever.
- Human-in-the-loop. Output is config files + a journal; nothing is promoted
to live, no
PAPER_TRADEtoggle, no order capability reachable. - Full audit. Every proposal, trial, and decision is journaled to
logs/research_journal.jsonland is replayable. - Sandbox + hygiene. Generated code and configs pass
quantred/research/sandbox.pybefore evaluation.
What the journal records for a session
A session writes research:session_start (goal, strategy, symbols, research window,
holdout window, budgets), then one research:trial line per evaluated round, plus
research:reject lines for proposals that never reached evaluation. A parameter round
is recorded under the session's strategy; a generated-code round under
draft:<Class>:<hash> of its source. Each trial line is dual-written into the trial
store under the same id. After the loop, each shortlisted candidate gets one
research:holdout_score line holding its single holdout score, and
research:session_end closes the session.
A walk-forward run elsewhere records its holdout in a holdout field on its own journal
line (days, metrics, params), written whenever a holdout was requested.
services.audit.holdout_from_record reads it back in one of four states: recorded,
none_requested (the recipe asked for none), not_retained (asked for, but the line
predates the field) and unknown (no readable line). An absent field is reported as
absent, never as a holdout that was not asked for.
Research hygiene (sandbox.py)
Cheap, load-bearing rules that stop the loop manufacturing overfit garbage at scale:
- Hypothesis before code. Every proposal must carry a rationale; no rationale ⇒ rejected unevaluated.
- ≤ 5 tunable parameters per generated strategy — more knobs = more overfit surface.
- Contract validation of agent-authored code: it must define a concrete
Strategysubclass that implements the abstract hooks, declares a validPARAM_RANGES, carries a docstring, and actually constructs. Generated code runs with a restricted global namespace (noos/sys/network imports, limited builtins) and is a proposal artifact, never auto-merged. Full OS-level process isolation is a documented future enhancement;load_strategy_from_codeis the single choke point where it would be enforced.
Provider-agnostic proposer (proposer.py, llm.py)
The loop and guardrails are proposer-agnostic. LLMProposer drives any
LLMClient:
AnthropicClient— Claude via theanthropicSDK (aiextra).OpenAIClient— GPT via theopenaiSDK (openaiextra).OllamaClient— any local model over the Ollama HTTP API (no dependency — standard library only).
build_proposer(provider, model) / build_llm_client(provider, model) pick the
backend. Credentials resolve through the shared settings chain — environment /
.env — via quantred.settings.get_credential
(ANTHROPIC_API_KEY / OPENAI_API_KEY; OLLAMA_BASE_URL for the local server).
FixedProposer replays a fixed list for deterministic offline tests.
The LLMClient contract and provider failures
LLMClient.complete is concrete; a backend implements _complete (the bare provider
call: one request, no error translation, no retry). Overriding complete instead no
longer instantiates, so every provider gets the same failure policy.
ModelProviderError(research/provider_errors.py) is what the loop sees. It carriesprovider, acategoryand a printabledetailand never the vendor's own message, which can echo request parameters and so the key. Categories:auth,quota(an empty balance),rate_limited,unavailable(5xx, Anthropic's 529overloaded_error),entitlement,unreachable,timeout,model_not_found,unknown.cmd_researchturns it into one line and a non-zero exit; the journal is left as it was.- Classification reads structured fields first (
code/typefrom the SDK error and its body:insufficient_quotaagainstrate_limit_exceeded, Anthropic'sbilling_error,rate_limit_error,overloaded_error), then the HTTP status, and consults message wording only for a 400/429 that nothing structured explained (Anthropic reports an empty balance as a plain 400). It recognises errors by shape and imports no SDK. It is checked against the realanthropicandopenaierror classes, not just fakes. - Only vendor and network exceptions are translated (
is_provider_exception: an HTTP status on the exception, an SDKAPI*Error,urllib/socket/http.clientfailures). AKeyErrorreading a reply, or any other bug here, propagates with its traceback instead of being reported as the provider's fault. A missing SDK (ImportError) also passes through. - Retry policy. The SDK clients are built with
max_retries=0and the wrapper owns the policy: up tomax_retries(default 2) retries, after 1s then 3s (or the provider'sRetry-After, unless it asks for more than 30s, in which case it stops), forrate_limited,unavailableandunreachableonly. An empty balance, a bad key, a forbidden or missing model are never re-sent, and neither is atimeout(waiting the same long time again is not a fix). The readiness probe setsmax_retries = 0: it is one request, always. - Ollama settings.
OLLAMA_BASE_URL(a full.../api/chatURL is trimmed),OLLAMA_TIMEOUT(seconds for one response, default 120; a slow GPU needs more) andOLLAMA_NUM_CTX(context length, sent only when set), all validated insettings.pyand by theinitwizard with the same parsers. A reply that is not JSON is reported asunavailableat that endpoint.
A "tune" proposal fixes specific parameter values and is validated as a
fixed-config walk-forward (WalkForwardValidator.evaluate_config), counting as one
trial toward the session's multiple-testing total. A "code" proposal (behind
--allow-code-gen) loads a new strategy class through the sandbox and runs a full
per-fold optimization over its PARAM_RANGES.