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:
@@ -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
19
src/fxhnt/adapters/llm.py
Normal 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}")
|
||||
6
src/fxhnt/application/agent/__init__.py
Normal file
6
src/fxhnt/application/agent/__init__.py
Normal 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"]
|
||||
33
src/fxhnt/application/agent/researcher.py
Normal file
33
src/fxhnt/application/agent/researcher.py
Normal 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
|
||||
51
src/fxhnt/application/agent/tools.py
Normal file
51
src/fxhnt/application/agent/tools.py
Normal 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]
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user