diff --git a/pyproject.toml b/pyproject.toml index dac66b4..cd0fae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/fxhnt/adapters/llm.py b/src/fxhnt/adapters/llm.py new file mode 100644 index 0000000..4ef3d1f --- /dev/null +++ b/src/fxhnt/adapters/llm.py @@ -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}") diff --git a/src/fxhnt/application/agent/__init__.py b/src/fxhnt/application/agent/__init__.py new file mode 100644 index 0000000..f994806 --- /dev/null +++ b/src/fxhnt/application/agent/__init__.py @@ -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"] diff --git a/src/fxhnt/application/agent/researcher.py b/src/fxhnt/application/agent/researcher.py new file mode 100644 index 0000000..5eaaef5 --- /dev/null +++ b/src/fxhnt/application/agent/researcher.py @@ -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 diff --git a/src/fxhnt/application/agent/tools.py b/src/fxhnt/application/agent/tools.py new file mode 100644 index 0000000..2e2a66c --- /dev/null +++ b/src/fxhnt/application/agent/tools.py @@ -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] diff --git a/src/fxhnt/cli.py b/src/fxhnt/cli.py index 7a35370..c4120c2 100644 --- a/src/fxhnt/cli.py +++ b/src/fxhnt/cli.py @@ -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)"), diff --git a/src/fxhnt/config.py b/src/fxhnt/config.py index e3ca771..035edb7 100644 --- a/src/fxhnt/config.py +++ b/src/fxhnt/config.py @@ -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) diff --git a/tests/integration/test_discovery.py b/tests/integration/test_discovery.py index 019fa28..3f6ddd3 100644 --- a/tests/integration/test_discovery.py +++ b/tests/integration/test_discovery.py @@ -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) diff --git a/tests/integration/test_portfolio_eval.py b/tests/integration/test_portfolio_eval.py index c9a0a71..539b2a5 100644 --- a/tests/integration/test_portfolio_eval.py +++ b/tests/integration/test_portfolio_eval.py @@ -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)