feat: agentic research layer — LangGraph agent + LLM port + compute tools

application/agent: ResearchAgent (LangGraph create_react_agent) + build_tools (list_strategies,
evaluate_candidate, evaluate_book wrapping the services) | adapters/llm.build_chat_model (config-
swappable: local Ollama now, cluster/API later) | AgentSettings | CLI agent. The LLM proposes; the
deterministic gauntlet inside the tools disposes. Verified: tools work + the model emits tool-calls
(architecture correct). Local qwen2.5:3b is too weak for the reliable multi-step loop (model bottleneck,
swappable via config). Also fixed flaky test seeds (hash()->deterministic). 14/14 tests. Bonus: tools
surfaced GC+NQ+ES trend book = Sharpe 1.06 OOS 1.47 DSR 0.949.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-09 16:22:59 +02:00
parent 84b2708af2
commit e628daba52
9 changed files with 145 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest>=8.0", "hypothesis>=6.100", "ruff>=0.5", "mypy>=1.10"]
ibkr = ["ib-async>=2.0"]
agent = ["langgraph>=0.2", "langchain-core>=0.3", "langchain-ollama>=0.2"]
data = ["databento>=0.50"]
[project.scripts]

19
src/fxhnt/adapters/llm.py Normal file
View File

@@ -0,0 +1,19 @@
"""LLM adapter — builds a LangChain chat model from config. LangChain's BaseChatModel IS the contract,
so the provider (local Ollama now; cluster-served or API later) is swappable without touching the agent.
"""
from __future__ import annotations
from fxhnt.config import Settings
def build_chat_model(settings: Settings): # type: ignore[no-untyped-def] # returns langchain BaseChatModel
a = settings.agent
if a.provider == "ollama":
from langchain_ollama import ChatOllama
return ChatOllama(model=a.model, base_url=a.base_url, temperature=a.temperature)
if a.provider == "openai":
from langchain_openai import ChatOpenAI
return ChatOpenAI(model=a.model, temperature=a.temperature)
raise ValueError(f"unknown LLM provider: {a.provider!r}")

View File

@@ -0,0 +1,6 @@
"""Agentic research layer — a LangGraph agent that drives the discover→test→learn loop using the
platform's compute tools. LLM proposes; the gauntlet disposes."""
from fxhnt.application.agent.researcher import ResearchAgent
from fxhnt.application.agent.tools import build_tools
__all__ = ["ResearchAgent", "build_tools"]

View File

@@ -0,0 +1,33 @@
"""The research agent — a LangGraph ReAct agent that proposes strategy-market candidates and diversified
books, tests them with the compute tools, and reports the most robust survivor. The LLM proposes;
the deterministic gauntlet (inside the tools) decides."""
from __future__ import annotations
from langgraph.prebuilt import create_react_agent
SYSTEM_PROMPT = """You are a quantitative researcher at fxhnt, an honest strategy-research platform.
Goal: find the most ROBUST diversified BOOK (a combination of strategy-market sleeves) you can.
House rules:
- The deflated-Sharpe gauntlet is the ONLY truth. A candidate or book counts only if it PASSES (DSR >= 0.95).
You cannot make something significant by claiming it — the math judges, not you.
- The unit of alpha is the diversified BOOK, not a single strategy. Single edges are usually marginal;
a book of uncorrelated edges is robust. Prefer evaluate_book over single candidates.
- Reason about strategy-market FIT: trend works on futures/commodities/FX (try CL, GC, ZN, 6E, ES, NQ as
asset_class=future); mean_reversion suits range-bound markets; buy_hold is the baseline to beat. Never
put mean_reversion on a clearly trending asset.
- Be systematic and concise. Call list_strategies first, then test a few candidates and at least one book.
Finish with: the single most robust BOOK you found (markets, strategy, Sharpe, DSR, PASS/REJECT) and one
sentence on why it is or is not robust."""
class ResearchAgent:
def __init__(self, llm, tools: list, recursion_limit: int = 40) -> None:
self._agent = create_react_agent(llm, tools, prompt=SYSTEM_PROMPT)
self._limit = recursion_limit
def run(self, goal: str) -> str:
out = self._agent.invoke({"messages": [("user", goal)]}, {"recursion_limit": self._limit})
return out["messages"][-1].content

View File

@@ -0,0 +1,51 @@
"""The compute tools the research agent is given. Each wraps a platform service; the agent reasons
about WHAT to test, the deterministic services do the testing. The gauntlet stays the truth-keeper —
the agent cannot make anything significant by proposing it."""
from __future__ import annotations
from langchain_core.tools import tool
from fxhnt.application.portfolio_eval import PortfolioEvaluationService
from fxhnt.application.research import ResearchService
from fxhnt.domain.models import AssetClass, Market, StrategySpec
from fxhnt.domain.portfolio import Book, StrategyAllocation
from fxhnt.domain.strategies import available, get_strategy
def build_tools(research: ResearchService, portfolio: PortfolioEvaluationService) -> list:
@tool
def list_strategies() -> str:
"""List the available strategy kinds and the structural rationale of each. Call this first."""
return "; ".join(f"{k}: {get_strategy(k).rationale}" for k in available())
@tool
def evaluate_candidate(symbol: str, asset_class: str, kind: str, window: int = 200, n_trials: int = 1) -> str:
"""Backtest ONE (market, strategy) and run it through the gauntlet. asset_class is future|etf|equity.
Returns Sharpe, out-of-sample Sharpe, deflated-Sharpe (DSR) and PASS/REJECT."""
try:
market = Market(symbol=symbol.upper(), asset_class=AssetClass(asset_class.lower()))
spec = StrategySpec(kind=kind, params={"window": float(window)})
r = research.evaluate_candidate(market, spec, n_trials=n_trials, persist=False)
flag = "PASS" if r.verdict.passed else "REJECT"
return f"{market} {spec.key()}: Sharpe {r.stats.sharpe:+.2f}, OOS {r.verdict.oos_sharpe:+.2f}, DSR {r.verdict.dsr:.3f} -> {flag}"
except Exception as e: # surface errors to the agent so it can adjust
return f"error: {str(e)[:140]}"
@tool
def evaluate_book(symbols: str, kind: str, window: int = 200, asset_class: str = "future", n_book_trials: int = 1) -> str:
"""Build a diversified equal-weight BOOK (one strategy across the comma-separated markets) and
validate it at PORTFOLIO level — the unit of alpha. Returns combined Sharpe, OOS, DSR, maxDD, PASS/REJECT."""
try:
markets = [Market(symbol=s.strip().upper(), asset_class=AssetClass(asset_class.lower()))
for s in symbols.split(",") if s.strip()]
weight = 1.0 / len(markets)
allocs = [StrategyAllocation(spec=StrategySpec(kind=kind, params={"window": float(window)}), market=m, weight=weight)
for m in markets]
ev = portfolio.evaluate_book(Book(name=f"{kind}-book", allocations=allocs), n_book_trials=n_book_trials)
flag = "PASS" if ev.verdict.passed else "REJECT"
return (f"BOOK {kind}(w={window}) on [{symbols}]: Sharpe {ev.stats.sharpe:+.2f}, OOS {ev.verdict.oos_sharpe:+.2f}, "
f"DSR {ev.verdict.dsr:.3f}, maxDD {100*ev.stats.max_drawdown:.0f}% -> {flag}")
except Exception as e:
return f"error: {str(e)[:140]}"
return [list_strategies, evaluate_candidate, evaluate_book]

View File

@@ -6,7 +6,9 @@ import typer
from fxhnt.adapters.broker import IbkrBroker
from fxhnt.adapters.data import DatabentoDataProvider, YahooDataProvider
from fxhnt.adapters.llm import build_chat_model
from fxhnt.adapters.persistence import DuckDbAnalyticalStore, SqlOperationalRepository
from fxhnt.application.agent import ResearchAgent, build_tools
from fxhnt.application import (
DiscoveryService,
ExecutionService,
@@ -126,6 +128,25 @@ def discover(
typer.echo(f" [PASS] {run.market} {run.spec.key():28} | Sharpe {run.stats.sharpe:+.2f} DSR {run.verdict.dsr:.3f} OOS {run.verdict.oos_sharpe:+.2f}")
@app.command()
def agent(
goal: str = typer.Option("Find the most robust diversified futures book you can (try CL, GC, ZN, 6E, ES, NQ).",
help="the research goal for the agent"),
data_source: str = typer.Option("databento", help="yahoo | databento"),
) -> None:
"""Run the LangGraph research agent: it proposes strategy-market books, tests them via the gauntlet
tools, and reports the most robust survivor. The LLM proposes; the deterministic gauntlet disposes."""
settings = get_settings()
provider = _data_provider(data_source, settings)
analytical = DuckDbAnalyticalStore(settings.analytical_path) # ONE shared store (DuckDB single-writer)
research = ResearchService(provider, SqlOperationalRepository(settings.operational_dsn), analytical, settings) # type: ignore[arg-type]
portfolio = PortfolioEvaluationService(provider, settings, analytical) # type: ignore[arg-type]
researcher = ResearchAgent(build_chat_model(settings), build_tools(research, portfolio),
recursion_limit=settings.agent.recursion_limit)
typer.echo(f"agent [{settings.agent.provider}:{settings.agent.model}] researching:\n {goal}\n")
typer.echo(researcher.run(goal))
@app.command("validate-book")
def validate_book(
symbols: str = typer.Option(..., help="markets in the book (comma-separated)"),

View File

@@ -51,6 +51,16 @@ class DatabentoSettings(BaseSettings):
default_start: str = "2018-05-01"
class AgentSettings(BaseSettings):
"""The research agent's LLM. Provider/model are swappable (local Ollama now; cluster/API later)."""
model_config = SettingsConfigDict(env_prefix="FXHNT_AGENT_")
provider: str = "ollama" # ollama | openai
model: str = "qwen2.5:3b"
base_url: str = "http://localhost:11434"
temperature: float = 0.2
recursion_limit: int = 40 # max agent reasoning/tool steps
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="FXHNT_", env_file=".env", extra="ignore")
@@ -65,6 +75,7 @@ class Settings(BaseSettings):
execution: ExecutionSettings = Field(default_factory=ExecutionSettings)
ibkr: IbkrSettings = Field(default_factory=IbkrSettings)
databento: DatabentoSettings = Field(default_factory=DatabentoSettings)
agent: AgentSettings = Field(default_factory=AgentSettings)
def ensure_dirs(self) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -14,7 +14,7 @@ class FakeData:
name = "fake"
def fetch(self, market: Market, start=None, end=None) -> PriceSeries:
rng = np.random.default_rng(abs(hash(market.symbol)) % 2**32)
rng = np.random.default_rng(sum(ord(c) for c in market.symbol)) # deterministic (hash() is randomized)
close = 100.0 * np.cumprod(1.0 + rng.normal(0.0003, 0.01, 1500))
return PriceSeries(market=market, dates=tuple(str(i) for i in range(1500)), close=close)

View File

@@ -15,7 +15,8 @@ class FakeData:
name = "fake"
def fetch(self, market: Market, start=None, end=None) -> PriceSeries:
rng = np.random.default_rng(abs(hash(market.symbol)) % 2**32) # uncorrelated per market
seed = sum(ord(c) for c in market.symbol) # deterministic (hash() is per-process randomized -> flaky)
rng = np.random.default_rng(seed) # uncorrelated per market
close = 100.0 * np.cumprod(1.0 + rng.normal(0.0005, 0.01, 1500)) # genuine drift -> diversified book clears the bar
return PriceSeries(market=market, dates=tuple(str(i) for i in range(1500)), close=close)