diff --git a/.env.example b/.env.example index be9bf13ebe..4b0e849ddc 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,50 @@ DEEPSEEK_API_KEY= DASHSCOPE_API_KEY= ZHIPU_API_KEY= OPENROUTER_API_KEY= + +# Hermes / automation-friendly routing +# Recommended modes: manual, cost_optimized +MODEL_ROUTING_MODE=cost_optimized + +# Primary provider for automated runs. Use ollama for home-PC local models, +# openrouter for free/cheap cloud models, or openai/google/anthropic for premium. +LLM_PROVIDER=ollama + +# OpenAI-compatible endpoint. For a home PC on Tailscale, use: +# LLM_BACKEND_URL=http://home-pc-tailnet-name:11434/v1 +LLM_BACKEND_URL=http://127.0.0.1:11434/v1 +OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 +OLLAMA_API_KEY=ollama + +# Default models for non-interactive Hermes runs +QUICK_THINK_LLM=qwen3:latest +DEEP_THINK_LLM=qwen3:latest +LOCAL_QUICK_MODEL=qwen3:latest +LOCAL_DEEP_MODEL=qwen3:latest + +# Optional OpenRouter free/cheap fallback model IDs +OPENROUTER_FREE_QUICK_MODEL= +OPENROUTER_FREE_DEEP_MODEL= +OPENROUTER_CHEAP_QUICK_MODEL= +OPENROUTER_CHEAP_DEEP_MODEL= + +# Optional premium fallback defaults +PAID_LLM_PROVIDER=openai +PAID_QUICK_MODEL=gpt-5.4-mini +PAID_DEEP_MODEL=gpt-5.4 + +# TradingAgents runtime paths and behavior +TRADINGAGENTS_RESULTS_DIR= +TRADINGAGENTS_CACHE_DIR= +TRADINGAGENTS_MEMORY_LOG_PATH= +TRADINGAGENTS_CHECKPOINT=false +TRADINGAGENTS_OUTPUT_LANGUAGE=English +TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 +TRADINGAGENTS_MAX_RISK_DISCUSS_ROUNDS=1 + +# Data vendors: yfinance or alpha_vantage +ALPHA_VANTAGE_API_KEY= +TRADINGAGENTS_CORE_STOCK_VENDOR=yfinance +TRADINGAGENTS_TECHNICAL_VENDOR=yfinance +TRADINGAGENTS_FUNDAMENTAL_VENDOR=yfinance +TRADINGAGENTS_NEWS_VENDOR=yfinance diff --git a/.gitignore b/.gitignore index 9a2904a9cc..16c872aedd 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,6 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -55,8 +53,14 @@ cover/ *.mo *.pot -# Django stuff: +# Framework/runtime state +reports/ +memory/*.jsonl +memory/*.tmp +logs/ *.log + +# Django stuff: local_settings.py db.sqlite3 db.sqlite3-journal @@ -83,48 +87,29 @@ profile_default/ ipython_config.py # pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. # Pipfile.lock # UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. # uv.lock # poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control # poetry.lock # poetry.toml # pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control # pdm.lock # pdm.toml .pdm-python .pdm-build/ # pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. # pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. .pixi -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +# PEP 582 __pypackages__/ # Celery stuff @@ -182,23 +167,12 @@ dmypy.json cython_debug/ # PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. # .idea/ # Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder # .vscode/ # Ruff stuff: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..6979cca9b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Agent Operating Rules + +This repository is an AI-assisted trading research framework. Agents may help write code, run research jobs, summarize results, and maintain documentation. + +## Hard boundaries + +- Do not add live broker execution in this repo without an explicit issue and review plan. +- Do not commit API keys, broker secrets, `.env`, generated reports, or memory logs. +- Do not remove paper-trading defaults or safety warnings. +- Do not make generated LLM text directly executable as broker orders. +- Do not push directly to `main`; use branches and pull requests. +- Do not weaken risk controls, audit logging, or kill-switch behavior. + +## Preferred workflow + +1. Inspect before editing. +2. Make small, reviewable changes. +3. Prefer deterministic code over LLM judgment where possible. +4. Use JSON schemas for machine-consumed outputs. +5. Save research outputs under `reports/` and lessons under `memory/`; these are ignored by git. +6. Keep Hermes scripts stable and explicit so the home-PC agent runs known commands instead of improvising. + +## Architecture rule + +```text +AI researches. +TradingOrg produces signals. +A separate app validates strategy and risk. +Broker execution is deterministic and gated. +``` + +## Model routing rule + +Use the cheapest reliable model tier first: + +1. deterministic code +2. local Ollama/home-PC model +3. free or cheap cloud model through OpenRouter or similar +4. premium cloud model only when quality or consequence justifies it + +Log provider/model details for any machine-consumed signal. diff --git a/docker-compose.yml b/docker-compose.yml index d28135b3c9..1e61521784 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,19 @@ services: build: . env_file: - .env + environment: + - LLM_PROVIDER=${LLM_PROVIDER:-ollama} + - LLM_BACKEND_URL=${LLM_BACKEND_URL:-http://host.docker.internal:11434/v1} + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434/v1} + - QUICK_THINK_LLM=${QUICK_THINK_LLM:-qwen3:latest} + - DEEP_THINK_LLM=${DEEP_THINK_LLM:-qwen3:latest} + - MODEL_ROUTING_MODE=${MODEL_ROUTING_MODE:-cost_optimized} volumes: - tradingagents_data:/home/appuser/.tradingagents + - ./reports:/home/appuser/app/reports + - ./memory:/home/appuser/app/memory + extra_hosts: + - "host.docker.internal:host-gateway" tty: true stdin_open: true @@ -21,8 +32,15 @@ services: - .env environment: - LLM_PROVIDER=ollama + - LLM_BACKEND_URL=${LLM_BACKEND_URL:-http://ollama:11434/v1} + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434/v1} + - QUICK_THINK_LLM=${QUICK_THINK_LLM:-qwen3:latest} + - DEEP_THINK_LLM=${DEEP_THINK_LLM:-qwen3:latest} + - MODEL_ROUTING_MODE=${MODEL_ROUTING_MODE:-cost_optimized} volumes: - tradingagents_data:/home/appuser/.tradingagents + - ./reports:/home/appuser/app/reports + - ./memory:/home/appuser/app/memory depends_on: - ollama tty: true diff --git a/docs/HERMES_CONTROL_PLANE.md b/docs/HERMES_CONTROL_PLANE.md new file mode 100644 index 0000000000..35ef10d4a7 --- /dev/null +++ b/docs/HERMES_CONTROL_PLANE.md @@ -0,0 +1,126 @@ +# Hermes Control Plane for TradingOrg + +This repository can be run as a Hermes-controlled research and post-game review engine. + +## Design + +Hermes is the home-PC operator. TradingOrg is the research engine. The trading app or broker executor must remain a separate deterministic, risk-gated system. + +```text +Hermes workspace / Hermes WebUI + -> runs TradingOrg commands and scripts + -> chooses local/free-cloud/premium model route + -> saves signal JSON and markdown reports + -> runs post-game review + -> appends lessons to memory/lessons.jsonl + +TradingOrg + -> produces research and signal JSON only + -> never places broker orders + +Trading app + -> consumes signals + -> applies strategy and risk controls + -> paper trades first + -> broker execution later, gated +``` + +## Non-negotiable safety rules + +- TradingOrg output is research only. +- Generated signals must default to `paper_only: true`. +- Broker keys do not belong in this repo. +- Hermes may run scripts, but must not directly place broker orders. +- No agent should push directly to `main`. +- Live trading requires a separate deterministic risk engine, kill switch, audit log, and manual approval path. + +## Local model over Tailscale + +On the home PC, run Ollama and expose it only on your private Tailscale network. + +```bash +ollama serve +ollama pull qwen3:latest +``` + +On the laptop or inside the container, test: + +```bash +curl http://home-pc-tailnet-name:11434/api/tags +curl http://home-pc-tailnet-name:11434/v1/models +``` + +`.env` example: + +```env +LLM_PROVIDER=ollama +LLM_BACKEND_URL=http://home-pc-tailnet-name:11434/v1 +OLLAMA_BASE_URL=http://home-pc-tailnet-name:11434/v1 +QUICK_THINK_LLM=qwen3:latest +DEEP_THINK_LLM=qwen3:latest +MODEL_ROUTING_MODE=cost_optimized +``` + +## Free/cheap cloud fallback + +OpenRouter can be used as a fallback when the local model is unavailable, slow, or too weak. + +```env +LLM_PROVIDER=openrouter +OPENROUTER_API_KEY=... +QUICK_THINK_LLM=your/free-or-cheap-model +DEEP_THINK_LLM=your/free-or-cheap-model +``` + +## Run one ticker + +Installed console script: + +```bash +tradingagents-analyze-run \ + --ticker NVDA \ + --date 2026-05-19 \ + --provider ollama \ + --backend-url http://home-pc-tailnet-name:11434/v1 \ + --quick-model qwen3:latest \ + --deep-model qwen3:latest \ + --analysts market,news,fundamentals \ + --depth 1 \ + --checkpoint \ + --output-json reports/signals/NVDA/2026-05-19/signal.json +``` + +Hermes-safe wrapper: + +```bash +scripts/hermes/run_single_ticker.sh NVDA 2026-05-19 +``` + +## Run a daily watchlist + +```bash +WATCHLIST="SPY,NVDA,AAPL,MSFT" scripts/hermes/run_daily_watchlist.sh +``` + +## Run post-game review + +```bash +scripts/hermes/run_postgame_review.sh NVDA 2026-05-19 +``` + +This creates: + +```text +reports/postgame/NVDA/2026-05-19/review.json +memory/lessons.jsonl +``` + +## Learning loop + +The intended loop is: + +```text +analyze -> signal -> observe result -> post-game review -> lesson -> next-run context +``` + +This is not model fine-tuning. It is reflection and memory injection. That is safer, cheaper, and easier to audit. diff --git a/pyproject.toml b/pyproject.toml index 07cbbd3f75..4b6d15a74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ dependencies = [ [project.scripts] tradingagents = "cli.main:app" +tradingagents-analyze-run = "tradingagents.automation.analyze_run:main" +tradingagents-postgame = "tradingagents.automation.postgame:main" +tradingagents-update-lessons = "tradingagents.automation.update_lessons:main" [tool.setuptools.packages.find] include = ["tradingagents*", "cli*"] diff --git a/scripts/hermes/run_daily_watchlist.sh b/scripts/hermes/run_daily_watchlist.sh new file mode 100644 index 0000000000..df361cc7c4 --- /dev/null +++ b/scripts/hermes/run_daily_watchlist.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +WATCHLIST="${WATCHLIST:-SPY,NVDA,AAPL,MSFT}" +ANALYSIS_DATE="${ANALYSIS_DATE:-$(date +%F)}" + +IFS=',' read -ra SYMBOLS <<< "${WATCHLIST}" +for symbol in "${SYMBOLS[@]}"; do + symbol="$(echo "${symbol}" | xargs)" + if [[ -z "${symbol}" ]]; then + continue + fi + echo "=== Running TradingOrg analysis for ${symbol} on ${ANALYSIS_DATE} ===" + "$(dirname "$0")/run_single_ticker.sh" "${symbol}" "${ANALYSIS_DATE}" +done + +echo "Daily watchlist complete for ${ANALYSIS_DATE}" diff --git a/scripts/hermes/run_postgame_review.sh b/scripts/hermes/run_postgame_review.sh new file mode 100644 index 0000000000..d1170032b3 --- /dev/null +++ b/scripts/hermes/run_postgame_review.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TICKER="${1:-${TICKER:-SPY}}" +ANALYSIS_DATE="${2:-${ANALYSIS_DATE:-$(date +%F)}}" +HOLDING_DAYS="${HOLDING_DAYS:-1}" +SIGNAL_PATH="${SIGNAL_PATH:-reports/signals/${TICKER}/${ANALYSIS_DATE}/signal.json}" +OUT_DIR="reports/postgame/${TICKER}/${ANALYSIS_DATE}" +REVIEW_PATH="${OUT_DIR}/review.json" +LESSONS_PATH="${LESSONS_PATH:-memory/lessons.jsonl}" + +mkdir -p "${OUT_DIR}" memory + +tradingagents-postgame \ + --signal "${SIGNAL_PATH}" \ + --ticker "${TICKER}" \ + --date "${ANALYSIS_DATE}" \ + --holding-days "${HOLDING_DAYS}" \ + --output-json "${REVIEW_PATH}" + +tradingagents-update-lessons \ + --review "${REVIEW_PATH}" \ + --lessons "${LESSONS_PATH}" + +echo "Review written to ${REVIEW_PATH}" +echo "Lesson appended to ${LESSONS_PATH}" diff --git a/scripts/hermes/run_single_ticker.sh b/scripts/hermes/run_single_ticker.sh new file mode 100644 index 0000000000..49b28be051 --- /dev/null +++ b/scripts/hermes/run_single_ticker.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +TICKER="${1:-${TICKER:-SPY}}" +ANALYSIS_DATE="${2:-${ANALYSIS_DATE:-$(date +%F)}}" +PROVIDER="${LLM_PROVIDER:-ollama}" +BACKEND_URL="${LLM_BACKEND_URL:-${OLLAMA_BASE_URL:-http://127.0.0.1:11434/v1}}" +QUICK_MODEL="${QUICK_THINK_LLM:-${LOCAL_QUICK_MODEL:-qwen3:latest}}" +DEEP_MODEL="${DEEP_THINK_LLM:-${LOCAL_DEEP_MODEL:-qwen3:latest}}" +ANALYSTS="${TRADINGORG_ANALYSTS:-market,news,fundamentals}" +DEPTH="${TRADINGORG_DEPTH:-1}" + +OUT_DIR="reports/signals/${TICKER}/${ANALYSIS_DATE}" +mkdir -p "${OUT_DIR}" + +tradingagents-analyze-run \ + --ticker "${TICKER}" \ + --date "${ANALYSIS_DATE}" \ + --provider "${PROVIDER}" \ + --backend-url "${BACKEND_URL}" \ + --quick-model "${QUICK_MODEL}" \ + --deep-model "${DEEP_MODEL}" \ + --analysts "${ANALYSTS}" \ + --depth "${DEPTH}" \ + --checkpoint \ + --output-json "${OUT_DIR}/signal.json" + +echo "Signal written to ${OUT_DIR}/signal.json" diff --git a/tradingagents/automation/__init__.py b/tradingagents/automation/__init__.py new file mode 100644 index 0000000000..b5a09d343e --- /dev/null +++ b/tradingagents/automation/__init__.py @@ -0,0 +1 @@ +"""Automation entrypoints for non-interactive TradingOrg workflows.""" diff --git a/tradingagents/automation/analyze_run.py b/tradingagents/automation/analyze_run.py new file mode 100644 index 0000000000..9a9de3884c --- /dev/null +++ b/tradingagents/automation/analyze_run.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import argparse +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from tradingagents.default_config import DEFAULT_CONFIG +from tradingagents.graph.trading_graph import TradingAgentsGraph + +_ALLOWED_ANALYSTS = {"market", "social", "news", "fundamentals"} + + +def _parse_analysts(value: str) -> list[str]: + analysts = [item.strip().lower() for item in value.split(",") if item.strip()] + invalid = [item for item in analysts if item not in _ALLOWED_ANALYSTS] + if invalid: + raise argparse.ArgumentTypeError( + f"Invalid analyst(s): {', '.join(invalid)}. Allowed: {', '.join(sorted(_ALLOWED_ANALYSTS))}" + ) + if not analysts: + raise argparse.ArgumentTypeError("At least one analyst is required.") + return analysts + + +def _build_config(args: argparse.Namespace) -> dict[str, Any]: + config = DEFAULT_CONFIG.copy() + config["llm_provider"] = args.provider.lower() + config["backend_url"] = args.backend_url + config["quick_think_llm"] = args.quick_model + config["deep_think_llm"] = args.deep_model + config["max_debate_rounds"] = args.depth + config["max_risk_discuss_rounds"] = args.depth + config["checkpoint_enabled"] = args.checkpoint + config["output_language"] = args.output_language + if args.results_dir: + config["results_dir"] = str(args.results_dir) + if args.cache_dir: + config["data_cache_dir"] = str(args.cache_dir) + return config + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def run(args: argparse.Namespace) -> dict[str, Any]: + config = _build_config(args) + graph = TradingAgentsGraph( + selected_analysts=args.analysts, + config=config, + debug=False, + ) + final_state, decision = graph.propagate(args.ticker, args.date) + + signal = { + "schema_version": "tradingorg.signal.v1", + "generated_at": datetime.now().isoformat(timespec="seconds"), + "ticker": args.ticker.upper(), + "analysis_date": args.date, + "provider": config["llm_provider"], + "backend_url": config.get("backend_url"), + "quick_model": config["quick_think_llm"], + "deep_model": config["deep_think_llm"], + "analysts": args.analysts, + "research_depth": args.depth, + "rating": decision, + "paper_only": True, + "final_trade_decision": final_state.get("final_trade_decision", ""), + "trader_investment_plan": final_state.get("trader_investment_plan", ""), + "investment_plan": final_state.get("investment_plan", ""), + "risk_debate_state": final_state.get("risk_debate_state", {}), + "investment_debate_state": final_state.get("investment_debate_state", {}), + "reports": { + "market": final_state.get("market_report"), + "sentiment": final_state.get("sentiment_report"), + "news": final_state.get("news_report"), + "fundamentals": final_state.get("fundamentals_report"), + }, + "execution_policy": { + "may_place_order": False, + "reason": "TradingOrg analyze-run produces research signals only. Broker execution must be handled by a separate deterministic risk-gated app.", + }, + } + + if args.output_json: + _write_json(args.output_json, signal) + + print(json.dumps(signal, indent=2, sort_keys=True)) + return signal + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run TradingOrg non-interactively for Hermes/workspace automation." + ) + parser.add_argument("--ticker", required=True, help="Ticker symbol to analyze, e.g. NVDA or SPY.") + parser.add_argument("--date", required=True, help="Analysis date in YYYY-MM-DD format.") + parser.add_argument("--provider", default=DEFAULT_CONFIG["llm_provider"], help="LLM provider, e.g. ollama, openrouter, openai, google.") + parser.add_argument("--backend-url", default=DEFAULT_CONFIG.get("backend_url"), help="Optional OpenAI-compatible base URL, e.g. http://home-pc:11434/v1.") + parser.add_argument("--quick-model", default=DEFAULT_CONFIG["quick_think_llm"], help="Model for quick analyst tasks.") + parser.add_argument("--deep-model", default=DEFAULT_CONFIG["deep_think_llm"], help="Model for deeper manager/portfolio tasks.") + parser.add_argument("--analysts", type=_parse_analysts, default=["market", "news", "fundamentals"], help="Comma-separated analysts: market,social,news,fundamentals.") + parser.add_argument("--depth", type=int, default=1, choices=[1, 3, 5], help="Research depth / debate rounds: 1, 3, or 5.") + parser.add_argument("--output-language", default=DEFAULT_CONFIG.get("output_language", "English"), help="Report language.") + parser.add_argument("--checkpoint", action="store_true", help="Enable LangGraph checkpoint resume.") + parser.add_argument("--output-json", type=Path, help="Where to write the machine-readable signal JSON.") + parser.add_argument("--results-dir", type=Path, help="Override results directory.") + parser.add_argument("--cache-dir", type=Path, help="Override cache/checkpoint directory.") + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + run(args) + + +if __name__ == "__main__": + main() diff --git a/tradingagents/automation/postgame.py b/tradingagents/automation/postgame.py new file mode 100644 index 0000000000..d88fac9db5 --- /dev/null +++ b/tradingagents/automation/postgame.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +import yfinance as yf + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def _fetch_return(symbol: str, trade_date: str, holding_days: int) -> dict[str, Any]: + start = datetime.strptime(trade_date, "%Y-%m-%d") + end = start + timedelta(days=holding_days + 7) + + data = yf.Ticker(symbol).history(start=start.strftime("%Y-%m-%d"), end=end.strftime("%Y-%m-%d")) + spy = yf.Ticker("SPY").history(start=start.strftime("%Y-%m-%d"), end=end.strftime("%Y-%m-%d")) + + if len(data) < 2 or len(spy) < 2: + return { + "available": False, + "reason": "Not enough market data yet for requested holding period.", + } + + idx = min(holding_days, len(data) - 1, len(spy) - 1) + start_close = float(data["Close"].iloc[0]) + end_close = float(data["Close"].iloc[idx]) + spy_start = float(spy["Close"].iloc[0]) + spy_end = float(spy["Close"].iloc[idx]) + raw_return = (end_close - start_close) / start_close + spy_return = (spy_end - spy_start) / spy_start + + return { + "available": True, + "start_close": start_close, + "end_close": end_close, + "raw_return": raw_return, + "spy_return": spy_return, + "alpha_vs_spy": raw_return - spy_return, + "actual_holding_days": idx, + } + + +def _direction_correct(rating: str, raw_return: float | None) -> bool | None: + if raw_return is None: + return None + bullish = rating in {"Buy", "Overweight"} + bearish = rating in {"Sell", "Underweight"} + neutral = rating == "Hold" + if bullish: + return raw_return > 0 + if bearish: + return raw_return < 0 + if neutral: + return abs(raw_return) < 0.01 + return None + + +def build_review(args: argparse.Namespace) -> dict[str, Any]: + signal = _read_json(args.signal) + ticker = args.ticker or signal.get("ticker") + analysis_date = args.date or signal.get("analysis_date") + rating = signal.get("rating", "Hold") + + outcome = _fetch_return(ticker, analysis_date, args.holding_days) + raw_return = outcome.get("raw_return") if outcome.get("available") else None + + review = { + "schema_version": "tradingorg.postgame.v1", + "generated_at": datetime.now().isoformat(timespec="seconds"), + "ticker": ticker, + "analysis_date": analysis_date, + "holding_days_requested": args.holding_days, + "original_rating": rating, + "provider": signal.get("provider"), + "quick_model": signal.get("quick_model"), + "deep_model": signal.get("deep_model"), + "outcome": outcome, + "direction_correct": _direction_correct(rating, raw_return), + "lesson": None, + "next_prompt_hint": None, + "notes": [ + "This post-game review is deterministic and outcome-based. Add an AI reflection step later if desired, but do not treat reflection as proof of strategy edge.", + ], + } + + if outcome.get("available"): + alpha = outcome["alpha_vs_spy"] + correctness = review["direction_correct"] + review["lesson"] = ( + f"Rating {rating} produced raw return {outcome['raw_return']:+.2%} " + f"and alpha vs SPY {alpha:+.2%} over {outcome['actual_holding_days']} trading day(s)." + ) + if correctness is True: + review["next_prompt_hint"] = "Keep this setup in memory, but require confirmation from price/volume and market regime before increasing confidence." + elif correctness is False: + review["next_prompt_hint"] = "Ask analysts to identify which thesis assumption failed and whether the signal conflicted with SPY/sector direction." + else: + review["next_prompt_hint"] = "For Hold ratings, evaluate whether opportunity cost or avoided drawdown was the real outcome." + + if args.output_json: + _write_json(args.output_json, review) + + return review + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create a post-game outcome review for a TradingOrg signal JSON.") + parser.add_argument("--signal", type=Path, required=True, help="Signal JSON from analyze-run.") + parser.add_argument("--ticker", help="Override ticker from signal JSON.") + parser.add_argument("--date", help="Override analysis date from signal JSON.") + parser.add_argument("--holding-days", type=int, default=1, help="Trading-day horizon to evaluate.") + parser.add_argument("--output-json", type=Path, help="Where to write post-game review JSON.") + return parser + + +def main() -> None: + args = build_parser().parse_args() + review = build_review(args) + print(json.dumps(review, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tradingagents/automation/update_lessons.py b/tradingagents/automation/update_lessons.py new file mode 100644 index 0000000000..28ba78a9f6 --- /dev/null +++ b/tradingagents/automation/update_lessons.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import argparse +import json +from datetime import datetime +from pathlib import Path +from typing import Any + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def append_lesson(review_path: Path, lessons_path: Path) -> dict[str, Any]: + review = _read_json(review_path) + lesson = { + "schema_version": "tradingorg.lesson.v1", + "recorded_at": datetime.now().isoformat(timespec="seconds"), + "ticker": review.get("ticker"), + "analysis_date": review.get("analysis_date"), + "original_rating": review.get("original_rating"), + "direction_correct": review.get("direction_correct"), + "outcome": review.get("outcome"), + "lesson": review.get("lesson"), + "next_prompt_hint": review.get("next_prompt_hint"), + "source_review": str(review_path), + } + lessons_path.parent.mkdir(parents=True, exist_ok=True) + with lessons_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(lesson, sort_keys=True) + "\n") + return lesson + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Append a post-game review lesson to lessons.jsonl.") + parser.add_argument("--review", type=Path, required=True, help="Post-game review JSON path.") + parser.add_argument("--lessons", type=Path, default=Path("memory/lessons.jsonl"), help="Lessons JSONL path.") + return parser + + +def main() -> None: + args = build_parser().parse_args() + lesson = append_lesson(args.review, args.lessons) + print(json.dumps(lesson, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index fa6d5742c8..cd18c88ae0 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -2,6 +2,24 @@ _TRADINGAGENTS_HOME = os.path.join(os.path.expanduser("~"), ".tradingagents") + +def _env_or_none(name: str) -> str | None: + value = os.getenv(name) + if value is None: + return None + value = value.strip() + return value or None + + +_DEFAULT_LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai").strip().lower() +_DEFAULT_BACKEND_URL = _env_or_none("LLM_BACKEND_URL") + +# Convenience alias for local/remote Ollama deployments. This lets a Hermes +# worker point TradingOrg at a home-PC Ollama server over Tailscale without +# needing to edit Python code. +if not _DEFAULT_BACKEND_URL and _DEFAULT_LLM_PROVIDER == "ollama": + _DEFAULT_BACKEND_URL = _env_or_none("OLLAMA_BASE_URL") + DEFAULT_CONFIG = { "project_dir": os.path.abspath(os.path.join(os.path.dirname(__file__), ".")), "results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR", os.path.join(_TRADINGAGENTS_HOME, "logs")), @@ -12,39 +30,45 @@ # Pending entries are never pruned. None disables rotation entirely. "memory_log_max_entries": None, # LLM settings - "llm_provider": "openai", - "deep_think_llm": "gpt-5.4", - "quick_think_llm": "gpt-5.4-mini", + "llm_provider": _DEFAULT_LLM_PROVIDER, + "deep_think_llm": os.getenv("DEEP_THINK_LLM", os.getenv("LOCAL_DEEP_MODEL", "gpt-5.4")), + "quick_think_llm": os.getenv("QUICK_THINK_LLM", os.getenv("LOCAL_QUICK_MODEL", "gpt-5.4-mini")), # When None, each provider's client falls back to its own default endpoint # (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...). - # The CLI overrides this per provider when the user picks one. Keeping a - # provider-specific URL here would leak (e.g. OpenAI's /v1 was previously - # being forwarded to Gemini, producing malformed request URLs). - "backend_url": None, + # Set LLM_BACKEND_URL or OLLAMA_BASE_URL to route OpenAI-compatible providers + # to a local/home-PC/free-cloud endpoint. + "backend_url": _DEFAULT_BACKEND_URL, + # High-level routing metadata used by Hermes scripts and future service mode. + "model_routing_mode": os.getenv("MODEL_ROUTING_MODE", "manual"), + "openrouter_free_quick_model": _env_or_none("OPENROUTER_FREE_QUICK_MODEL"), + "openrouter_free_deep_model": _env_or_none("OPENROUTER_FREE_DEEP_MODEL"), + "paid_llm_provider": os.getenv("PAID_LLM_PROVIDER", "openai").strip().lower(), + "paid_quick_model": os.getenv("PAID_QUICK_MODEL", "gpt-5.4-mini"), + "paid_deep_model": os.getenv("PAID_DEEP_MODEL", "gpt-5.4"), # Provider-specific thinking configuration - "google_thinking_level": None, # "high", "minimal", etc. - "openai_reasoning_effort": None, # "medium", "high", "low" - "anthropic_effort": None, # "high", "medium", "low" + "google_thinking_level": _env_or_none("GOOGLE_THINKING_LEVEL"), # "high", "minimal", etc. + "openai_reasoning_effort": _env_or_none("OPENAI_REASONING_EFFORT"), # "medium", "high", "low" + "anthropic_effort": _env_or_none("ANTHROPIC_EFFORT"), # "high", "medium", "low" # Checkpoint/resume: when True, LangGraph saves state after each node # so a crashed run can resume from the last successful step. - "checkpoint_enabled": False, + "checkpoint_enabled": os.getenv("TRADINGAGENTS_CHECKPOINT", "false").strip().lower() in {"1", "true", "yes", "on"}, # Output language for analyst reports and final decision # Internal agent debate stays in English for reasoning quality - "output_language": "English", + "output_language": os.getenv("TRADINGAGENTS_OUTPUT_LANGUAGE", "English"), # Debate and discussion settings - "max_debate_rounds": 1, - "max_risk_discuss_rounds": 1, + "max_debate_rounds": int(os.getenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "1")), + "max_risk_discuss_rounds": int(os.getenv("TRADINGAGENTS_MAX_RISK_DISCUSS_ROUNDS", "1")), "max_recur_limit": 100, # Data vendor configuration # Category-level configuration (default for all tools in category) "data_vendors": { - "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance - "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance - "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance - "news_data": "yfinance", # Options: alpha_vantage, yfinance + "core_stock_apis": os.getenv("TRADINGAGENTS_CORE_STOCK_VENDOR", "yfinance"), # Options: alpha_vantage, yfinance + "technical_indicators": os.getenv("TRADINGAGENTS_TECHNICAL_VENDOR", "yfinance"), # Options: alpha_vantage, yfinance + "fundamental_data": os.getenv("TRADINGAGENTS_FUNDAMENTAL_VENDOR", "yfinance"), # Options: alpha_vantage, yfinance + "news_data": os.getenv("TRADINGAGENTS_NEWS_VENDOR", "yfinance"), # Options: alpha_vantage, yfinance }, # Tool-level configuration (takes precedence over category-level) "tool_vendors": { # Example: "get_stock_data": "alpha_vantage", # Override category default }, -} +} \ No newline at end of file diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index a2c57ed893..02f0e79dc7 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -105,11 +105,13 @@ ("Qwen3:latest (8B, local)", "qwen3:latest"), ("GPT-OSS:latest (20B, local)", "gpt-oss:latest"), ("GLM-4.7-Flash:latest (30B, local)", "glm-4.7-flash:latest"), + ("Custom model ID", "custom"), ], "deep": [ ("GLM-4.7-Flash:latest (30B, local)", "glm-4.7-flash:latest"), ("GPT-OSS:latest (20B, local)", "gpt-oss:latest"), ("Qwen3:latest (8B, local)", "qwen3:latest"), + ("Custom model ID", "custom"), ], }, } @@ -128,7 +130,8 @@ def get_known_models() -> Dict[str, List[str]]: value for options in mode_options.values() for _, value in options + if value != "custom" } ) for provider, mode_options in MODEL_OPTIONS.items() - } + } \ No newline at end of file diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index bbfcd39e3a..cbbd76cd7c 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -56,8 +56,10 @@ class OpenAIClient(BaseLLMClient): For native OpenAI models, uses the Responses API (/v1/responses) which supports reasoning_effort with function tools across all model families - (GPT-4.1, GPT-5). Third-party compatible providers (xAI, OpenRouter, - Ollama) use standard Chat Completions. + (GPT-4.1, GPT-5). Third-party compatible providers use standard Chat + Completions. A caller-provided base_url always wins over provider defaults, + which allows remote Ollama or other OpenAI-compatible endpoints over + Tailscale/private networks. """ def __init__( @@ -75,16 +77,18 @@ def get_llm(self) -> Any: self.warn_if_unknown_model() llm_kwargs = {"model": self.model} - # Provider-specific base URL and auth + # Provider-specific base URL and auth. Explicit base_url wins so Hermes + # can route Ollama/OpenAI-compatible traffic to a home PC over Tailscale + # or to a compatible free/cheap cloud endpoint. if self.provider in _PROVIDER_CONFIG: - base_url, api_key_env = _PROVIDER_CONFIG[self.provider] - llm_kwargs["base_url"] = base_url + provider_base_url, api_key_env = _PROVIDER_CONFIG[self.provider] + llm_kwargs["base_url"] = self.base_url or provider_base_url if api_key_env: api_key = os.environ.get(api_key_env) if api_key: llm_kwargs["api_key"] = api_key else: - llm_kwargs["api_key"] = "ollama" + llm_kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "ollama") elif self.base_url: llm_kwargs["base_url"] = self.base_url @@ -102,4 +106,4 @@ def get_llm(self) -> Any: def validate_model(self) -> bool: """Validate model for the provider.""" - return validate_model(self.provider, self.model) + return validate_model(self.provider, self.model) \ No newline at end of file