Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

[![CI](https://github.com/OnePunchMonk/AgentQuant/actions/workflows/ci.yml/badge.svg)](https://github.com/OnePunchMonk/AgentQuant/actions)
![Python](https://img.shields.io/badge/python-3.10%2B-blue)
![Tests](https://img.shields.io/badge/tests-42%20passed-brightgreen)
![Tests](https://img.shields.io/badge/tests-55%20passed-brightgreen)

---

Expand All @@ -20,6 +20,30 @@ AgentQuant is a regime-adaptive research platform that runs a real **ReAct agent

---

## Platform Preview

### Live Data Selection

Choose a date range, select preset stocks/ETFs, or type any yfinance ticker. AgentQuant fetches data on demand and only uses the local cache when it covers the requested range.

![Live data sidebar](screenshots/live_data_sidebar_desktop.jpg)

### Research Workspace

The dashboard tracks experiment runs, baselines, robustness scores, validation checks, and report-ready research notes in one place.

![Research workspace](screenshots/research_workspace_desktop.jpg)

### Alpha + NLA Memory

Agent Lab stores backtested alpha candidates and explicit NLA-style research narratives so future runs can retrieve prior evidence. NLA memory is based on explicit activation narratives or imported `nla-gemma4` JSONL outputs, not hidden chain-of-thought.

![NLA memory](screenshots/nla_memory_desktop.jpg)

![Agent Lab NLA memory](screenshots/agent_lab_nla_memory_desktop.jpg)

---

## Architecture

```
Expand All @@ -40,6 +64,9 @@ analyze ──► hypothesize ──► backtest ──► reflect
| `src/agent/context_builder.py` | `RegimeContext` dataclass with VIX percentile, multi-horizon momentum |
| `src/agent/parameter_grid.py` | Canonical grids per strategy; regime-aware prior selection |
| `src/agent/strategy_memory.py` | SQLite cross-session memory |
| `src/research/alpha_store.py` | SQLite memory for accepted, watchlisted, and rejected alpha candidates |
| `src/research/nla_memory.py` | Explicit NLA-style narrative memory and `nla-gemma4` JSONL ingestion |
| `src/research/workspace.py` | Experiment registry, robustness summaries, and research memo generation |
| `src/features/regime.py` | Percentile-based regime detection + optional HMM |
| `src/features/engine.py` | RSI, MACD, Bollinger, ATR, multi-horizon vol, stationarity checks |
| `src/features/lookback_guard.py` | `WarmupEnforcer` prevents look-ahead bias |
Expand Down Expand Up @@ -108,14 +135,18 @@ pip install -e ".[dev]"
pytest tests/ -v
```

**42 tests passing** across:
**55 tests passing** across:
- `test_config.py` — Pydantic validation
- `test_data_ingest.py` — live ticker fetch and cache range coverage
- `test_metrics.py` — Sharpe, drawdown, Calmar, Sortino
- `test_regime.py` — VIX percentile regime classification
- `test_features.py` — RSI bounds, momentum accuracy, new indicator columns
- `test_strategies.py` — All 6 strategies produce valid `{-1,0,1}` signals
- `test_backtest.py` — Runner, zero-signal flat equity, metrics keys
- `test_proposal_generator.py` — Fallback chain without API key
- `test_alpha_store.py` — alpha memory persistence and retrieval
- `test_nla_memory.py` — explicit NLA memory and JSONL ingestion
- `test_research_workspace.py` — experiment registry summaries and memos

---

Expand All @@ -136,6 +167,10 @@ AgentQuant/
│ ├── data/
│ │ ├── ingest.py # yfinance + FRED with TTL cache
│ │ └── schemas.py # Data schemas
│ ├── research/
│ │ ├── alpha_store.py # SQLite alpha candidate memory
│ │ ├── nla_memory.py # Explicit NLA narrative memory
│ │ └── workspace.py # Experiment registry + research memos
│ ├── features/
│ │ ├── engine.py # RSI, MACD, Bollinger, ATR, multi-horizon vol
│ │ ├── regime.py # VIX-percentile + optional HMM detection
Expand All @@ -158,7 +193,7 @@ AgentQuant/
├── experiments/
│ ├── results_store.py # SQLite experiment tracking
│ └── walk_forward.py # Walk-forward validation
├── tests/ # 42 tests
├── tests/ # 55 tests
├── docs/ # Documentation
├── config.yaml # Project configuration
├── .env.example # Environment template
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"scipy>=1.12",
"streamlit>=1.39",
"plotly>=5.17",
"matplotlib>=3.8",
"pyarrow>=16.0",
"tabulate>=0.9",
"statsmodels>=0.14",
Expand Down Expand Up @@ -67,4 +68,4 @@ ignore = ["E501"]

[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
include = ["src*"]
Binary file added screenshots/agent_lab_nla_memory_desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/live_data_sidebar_desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/nla_memory_desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/research_workspace_desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 43 additions & 6 deletions src/agent/agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,15 @@

import json
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, TypedDict

import numpy as np
import pandas as pd

from src.agent.context_builder import RegimeContext, build_context
from src.agent.proposal_generator import Proposal, ProposalGenerator
from src.agent.strategy_memory import PastResult, StrategyMemory
from src.backtest.metrics import PerformanceMetrics
from src.research.alpha_store import AlphaStore
from src.research.nla_memory import NLAMemoryStore
from src.utils.config import config

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -60,10 +59,16 @@ def analyze_node(state: AgentState) -> AgentState:
# Get memory context
memory = StrategyMemory()
memory_ctx = memory.to_prompt_context(regime_label, state.get("strategy_type", "momentum"))
alpha_memory = AlphaStore()
alpha_ctx = alpha_memory.to_prompt_context(regime_label, state.get("strategy_type", "momentum"))
nla_memory = NLAMemoryStore()
nla_ctx = nla_memory.to_prompt_context(regime_label, state.get("strategy_type", "momentum"))
context.alpha_memory_context = alpha_ctx
context.nla_memory_context = nla_ctx

state["features_df"] = features_df
state["context"] = context
state["memory_context"] = memory_ctx
state["memory_context"] = f"{memory_ctx}\n\n{alpha_ctx}\n\n{nla_ctx}"
state["run_log"] = state.get("run_log", [])
state["run_log"].append(f"Regime: {regime_label} (confidence: {context.regime_confidence:.0%})")

Expand Down Expand Up @@ -212,8 +217,40 @@ def store_node(state: AgentState) -> AgentState:
reasoning=best.get("reasoning", ""),
)
run_id = memory.store(result)
state["run_log"].append(f"Store: Persisted result {run_id} to memory.")
logger.info("Persisted result %s to strategy memory.", run_id)
alpha = AlphaStore().store_backtest_result(
regime=regime,
strategy_type=state.get("strategy_type", "momentum"),
params=best["params"],
metrics={
"sharpe_ratio": best.get("sharpe", 0.0),
"total_return": best.get("total_return", 0.0),
"max_drawdown": best.get("max_drawdown", 0.0),
"num_trades": best.get("num_trades", 0),
},
assets=[state.get("asset", config.reference_asset)],
generation_method=best.get("generation_method", ""),
confidence=best.get("confidence", 0.0),
reasoning=best.get("reasoning", ""),
source="agent_graph",
)
nla = NLAMemoryStore().store_agent_summary(
regime=regime,
strategy_type=state.get("strategy_type", "momentum"),
params=best["params"],
metrics={
"sharpe_ratio": best.get("sharpe", 0.0),
"total_return": best.get("total_return", 0.0),
"max_drawdown": best.get("max_drawdown", 0.0),
"num_trades": best.get("num_trades", 0),
},
narrative=best.get("reasoning", "") or "Stored best proposal from explicit agent run.",
alpha_id=alpha.alpha_id,
tags=("agent_graph", best.get("generation_method", "")),
)
state["run_log"].append(
f"Store: Persisted result {run_id}, alpha {alpha.alpha_id}, NLA note {nla.record_id}."
)
logger.info("Persisted result %s, alpha %s, NLA note %s.", run_id, alpha.alpha_id, nla.record_id)
return state


Expand Down
13 changes: 9 additions & 4 deletions src/agent/context_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@
"""

import logging
from dataclasses import dataclass, field
from typing import Optional
from dataclasses import dataclass

import numpy as np
import pandas as pd
from scipy import stats as scipy_stats

Expand All @@ -36,10 +34,12 @@ class RegimeContext:
rsi_14: float = 50.0
price_vs_sma200: float = 0.0
regime_confidence: float = 0.5
alpha_memory_context: str = ""
nla_memory_context: str = ""

def to_prompt_string(self) -> str:
"""Format context as structured text for LLM prompt injection."""
return (
context = (
f"MARKET CONTEXT:\n"
f" Regime: {self.regime_label} (confidence: {self.regime_confidence:.0%})\n"
f" VIX: {self.vix_level:.1f} (at {self.vix_percentile:.0f}th percentile, trailing 1Y)\n"
Expand All @@ -55,6 +55,11 @@ def to_prompt_string(self) -> str:
f" RSI (14): {self.rsi_14:.1f}\n"
f" Drawdown from peak: {self.drawdown_from_peak * 100:.1f}%\n"
)
if self.alpha_memory_context:
context += f"\n{self.alpha_memory_context}\n"
if self.nla_memory_context:
context += f"\n{self.nla_memory_context}\n"
return context


def build_context(features_df: pd.DataFrame) -> RegimeContext:
Expand Down
75 changes: 72 additions & 3 deletions src/agent/proposal_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from src.agent.base_planner import BasePlanner, create_planner
from src.agent.context_builder import RegimeContext
from src.agent.parameter_grid import ParameterGrid
from src.research.alpha_store import AlphaStore

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -94,10 +95,17 @@ class ProposalGenerator:
Fallback chain: LLM → GridSearch → Random.
"""

def __init__(self, planner: Optional[BasePlanner] = None):
def __init__(
self,
planner: Optional[BasePlanner] = None,
alpha_store: Optional[AlphaStore] = None,
use_alpha_memory: bool = True,
):
self.planner = planner or create_planner()
self.grid = ParameterGrid()
self.validator = ProposalValidator()
self.alpha_store = alpha_store or AlphaStore()
self.use_alpha_memory = use_alpha_memory

def generate(
self,
Expand All @@ -120,14 +128,27 @@ def generate(
if len(proposals) < n_proposals:
needed = n_proposals - len(proposals)
existing_params = {tuple(sorted(p.params.items())) for p in proposals}
rejected_params = self._rejected_param_keys(context, strategy_type)

if self.use_alpha_memory:
memory_proposals = self._memory_generate(context, strategy_type, needed)
for mp in memory_proposals:
if len(proposals) >= n_proposals:
break
key = tuple(sorted(mp.params.items()))
if key not in existing_params:
proposals.append(mp)
existing_params.add(key)

needed = n_proposals - len(proposals)
grid_proposals = self.grid.top_k_by_prior(
strategy_type, needed + 3, context.regime_label
)
for gp in grid_proposals:
if len(proposals) >= n_proposals:
break
key = tuple(sorted(gp.items()))
if key not in existing_params:
if key not in existing_params and key not in rejected_params:
proposals.append(Proposal(
params=gp,
confidence=0.3,
Expand All @@ -139,12 +160,13 @@ def generate(
# Last resort: random from grid
if len(proposals) < n_proposals:
needed = n_proposals - len(proposals)
rejected_params = self._rejected_param_keys(context, strategy_type)
for rp in self.grid.random_k(strategy_type, needed + 5):
if len(proposals) >= n_proposals:
break
existing_params_set = {tuple(sorted(p.params.items())) for p in proposals}
key = tuple(sorted(rp.items()))
if key not in existing_params_set:
if key not in existing_params_set and key not in rejected_params:
proposals.append(Proposal(
params=rp,
confidence=0.1,
Expand All @@ -154,6 +176,53 @@ def generate(

return proposals[:n_proposals]

def _memory_generate(
self,
context: RegimeContext,
strategy_type: str,
n: int,
) -> List[Proposal]:
if n <= 0:
return []

grid_keys = {
tuple(sorted(params.items()))
for params in self.grid.get_grid(strategy_type)
}
proposals: List[Proposal] = []
candidates = self.alpha_store.recall(
regime=context.regime_label,
strategy_type=strategy_type,
statuses=("accepted", "watch"),
n=n,
)

for candidate in candidates:
key = tuple(sorted(candidate.params.items()))
if grid_keys and key not in grid_keys:
continue
proposals.append(
Proposal(
params=candidate.params,
confidence=max(candidate.confidence, 0.55),
regime_characteristic_used="alpha_memory",
reasoning=f"Retrieved from alpha DB: {candidate.thesis}",
generation_method="alpha_memory",
)
)
return proposals

def _rejected_param_keys(self, context: RegimeContext, strategy_type: str) -> set:
if not self.use_alpha_memory:
return set()
rejected = self.alpha_store.recall(
regime=context.regime_label,
strategy_type=strategy_type,
statuses=("rejected",),
n=50,
)
return {tuple(sorted(candidate.params.items())) for candidate in rejected}

def _llm_generate(
self, context: RegimeContext, strategy_type: str, n: int
) -> List[Proposal]:
Expand Down
Loading
Loading