From 30c0a9166e1443df144ecc1bb3edbca131d2e999 Mon Sep 17 00:00:00 2001 From: TPTBusiness Date: Sun, 22 Mar 2026 21:20:02 +0100 Subject: [PATCH] chore: initial Predix state (RD-Agent fork + EURUSD setup) --- .bumpversion.cfg | 6 - .commitlintrc.js | 21 - .devcontainer/Dockerfile | 10 - .devcontainer/README.md | 39 - .devcontainer/env | 47 - .env.example | 61 - .github/FUNDING.yml | 2 - .github/ISSUE_TEMPLATE/bug-report.md | 51 - .github/ISSUE_TEMPLATE/documentation.md | 9 - .github/ISSUE_TEMPLATE/feature-request.md | 25 - .github/ISSUE_TEMPLATE/question.md | 10 - .github/PULL_REQUEST_TEMPLATE.md | 34 - .github/dependabot.yml | 19 - .github/workflows/ci.yml | 69 - .github/workflows/pr.yml | 35 - .github/workflows/readthedocs-preview.yml | 17 - .github/workflows/release.yml | 48 - .gitignore | 200 +-- .readthedocs.yaml | 38 - .streamlit/config.toml | 2 - LICENSE | 675 ++++++++- rdagent/app/qlib_rd_loop/prompts.yaml | 54 +- .../components/coder/model_coder/prompts.yaml | 9 + rdagent/log/__init__.py | 4 - rdagent/log/base.py | 103 -- rdagent/log/conf.py | 34 - rdagent/log/logger.py | 153 --- rdagent/log/mle_summary.py | 260 ---- rdagent/log/server/README.md | 253 ---- rdagent/log/server/app.py | 562 -------- rdagent/log/server/debug_app.py | 174 --- rdagent/log/storage.py | 116 -- rdagent/log/timer.py | 86 -- rdagent/log/ui/__init__.py | 7 - rdagent/log/ui/aide.py | 53 - rdagent/log/ui/app.py | 1133 ---------------- rdagent/log/ui/conf.py | 24 - rdagent/log/ui/ds_summary.py | 208 --- rdagent/log/ui/ds_trace.py | 1205 ----------------- rdagent/log/ui/ds_user_interact.py | 172 --- rdagent/log/ui/dsapp.py | 51 - rdagent/log/ui/flow.png | Bin 125836 -> 0 bytes rdagent/log/ui/llm_st.py | 306 ----- rdagent/log/ui/qlib_report_figure.py | 445 ------ rdagent/log/ui/st_fixed_container.py | 126 -- rdagent/log/ui/storage.py | 345 ----- rdagent/log/ui/utils.py | 1128 --------------- rdagent/log/ui/web.py | 629 --------- rdagent/log/utils/__init__.py | 129 -- rdagent/log/utils/folder.py | 77 -- setup_predix_eurusd.sh | 301 ++++ 51 files changed, 1026 insertions(+), 8539 deletions(-) delete mode 100644 .bumpversion.cfg delete mode 100644 .commitlintrc.js delete mode 100644 .devcontainer/Dockerfile delete mode 100644 .devcontainer/README.md delete mode 100644 .devcontainer/env delete mode 100644 .env.example delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug-report.md delete mode 100644 .github/ISSUE_TEMPLATE/documentation.md delete mode 100644 .github/ISSUE_TEMPLATE/feature-request.md delete mode 100644 .github/ISSUE_TEMPLATE/question.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/pr.yml delete mode 100644 .github/workflows/readthedocs-preview.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .readthedocs.yaml delete mode 100644 .streamlit/config.toml delete mode 100644 rdagent/log/__init__.py delete mode 100644 rdagent/log/base.py delete mode 100644 rdagent/log/conf.py delete mode 100644 rdagent/log/logger.py delete mode 100644 rdagent/log/mle_summary.py delete mode 100644 rdagent/log/server/README.md delete mode 100644 rdagent/log/server/app.py delete mode 100644 rdagent/log/server/debug_app.py delete mode 100644 rdagent/log/storage.py delete mode 100644 rdagent/log/timer.py delete mode 100644 rdagent/log/ui/__init__.py delete mode 100644 rdagent/log/ui/aide.py delete mode 100644 rdagent/log/ui/app.py delete mode 100644 rdagent/log/ui/conf.py delete mode 100755 rdagent/log/ui/ds_summary.py delete mode 100644 rdagent/log/ui/ds_trace.py delete mode 100644 rdagent/log/ui/ds_user_interact.py delete mode 100644 rdagent/log/ui/dsapp.py delete mode 100644 rdagent/log/ui/flow.png delete mode 100644 rdagent/log/ui/llm_st.py delete mode 100644 rdagent/log/ui/qlib_report_figure.py delete mode 100644 rdagent/log/ui/st_fixed_container.py delete mode 100644 rdagent/log/ui/storage.py delete mode 100644 rdagent/log/ui/utils.py delete mode 100644 rdagent/log/ui/web.py delete mode 100644 rdagent/log/utils/__init__.py delete mode 100644 rdagent/log/utils/folder.py create mode 100755 setup_predix_eurusd.sh diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 886cccdb..00000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[bumpversion] -current_version = 0.0.0 -commit = True -tag = True - -[bumpversion:file:pyproject.toml] diff --git a/.commitlintrc.js b/.commitlintrc.js deleted file mode 100644 index 0a4c1ac2..00000000 --- a/.commitlintrc.js +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = { - extends: ["@commitlint/config-conventional"], - rules: { - // Configuration Format: [level, applicability, value] - // level: Error level, usually expressed as a number: - // 0 - disable rule - // 1 - Warning (does not prevent commits) - // 2 - Error (will block the commit) - // applicability: the conditions under which the rule applies, commonly used values: - // โ€œalwaysโ€ - always apply the rule - // โ€œneverโ€ - never apply the rule - // value: the specific value of the rule, e.g. a maximum length of 100. - // Refs: https://commitlint.js.org/reference/rules-configuration.html - "header-max-length": [2, "always", 100], - "type-enum": [ - 2, - "always", - ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test", "Release-As"] - ] - } - }; diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index d4d79f68..00000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -# 1. Pull down your Azure Container Registry image -FROM rdagentappregistry.azurecr.io/rd-agent-mle:20250623 - -# 2. (Optional) install any additional tools you need -# e.g. git, bash-completion, etc. -# RUN apt update && \ -# apt install -y git bash-completion && \ -# rm -rf /var/lib/apt/lists/* -RUN apt update && \ - apt install -y git bash-completion diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index 1020eb75..00000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Introduction - -!!!!!This dev container is not for public development!!!!!! -!!!!!Please don't use it if you are just a public open-source user.!!!!!! - -# Steps to run the dev container (for internal use only) - -Prerequisites(this is the reason why this dev container is not for public use): - -- Make sure you have the `rdagentappregistry.azurecr.io/rd-agent-mle:20250623` image locally & DevContainer is installed in your IDE -- The kaggle dataset is located at `/home/shared/RD-Agent/kaggle` - -1. Open the project and select "Open In DevContainer" -2. Set up your Kaggle Key (do not share this; other internal URLs are hardcoded in the config files) - -```bash -export KAGGLE_USERNAME= -export KAGGLE_KEY= -``` - -3. Run: python rdagent/app/data_science/loop.py --competition nomad2018-predict-transparent-conductors - - -# Additional Notes -- Please install and use this Dev Container in VS Code. -- You **must open VS Code remotely and enter the `RD-Agent` directory before running the DevContainer configuration (`.devcontainer/devcontainer.json`)**. Otherwise, the workspace and path mappings will not work as expected. -- To open the DevContainer correctly in VS Code: - 1. Remotely connect to the machine and open the `RD-Agent` folder in VS Code. - 2. Press `Ctrl+Shift+P` (or `Cmd+Shift+P` on Mac), type and select **"Dev Containers: Reopen in Container"**. - - - -# How to grade your submission in the DevContainer - -1. save your submission file in `./sumission.csv` - -2. Run evaluation -DS_COMPETITION= -conda run -n mlebench mlebench grade-sample submission.csv $DS_COMPETITION --data-dir /tmp/kaggle/zip_files/ \ No newline at end of file diff --git a/.devcontainer/env b/.devcontainer/env deleted file mode 100644 index 8282f374..00000000 --- a/.devcontainer/env +++ /dev/null @@ -1,47 +0,0 @@ -# Global configs: - -MAX_RETRY=12000 -RETRY_WAIT_SECONDS=5 -TIMEOUT_FAIL_LIMIT=100 - -# litellm -# CHAT_MODEL=gpt-4o -# CHAT_TEMPERATURE=0.7 - -CHAT_STREAM=False -CHAT_TEMPERATURE=1 -CHAT_MODEL=o1-preview -SYSTEM_PROMPT_ROLE=user - -BACKEND=rdagent.oai.backend.LiteLLMAPIBackend -OPENAI_API_KEY=sk-1234 -OPENAI_API_BASE=http://ep14.213428.xyz:38881 - - -# amc chat model configs: -EMBEDDING_MODEL=text-embedding-ada-002 - -# Cache Setting (Optional): -DUMP_CHAT_CACHE=True -USE_CHAT_CACHE=False -DUMP_EMBEDDING_CACHE=True -USE_EMBEDDING_CACHE=False -LOG_LLM_CHAT_CONTENT=True - -DS_LOCAL_DATA_PATH=/tmp/kaggle - -DS_IF_USING_MLE_DATA=True - - -PICKLE_CACHE_FOLDER_PATH_STR=./log/pickle_cache -CACHE_WITH_PICKLE=False -ENABLE_CACHE=False -PROMPT_CACHE_PATH=./log/prompt_cache.db - -DS_CODER_COSTEER_ENV_TYPE=conda -# DS_PROPOSAL_VERSION=v2 deprecated - -DS_CODER_ON_WHOLE_PIPELINE=True -COSTEER_V2_QUERY_FORMER_TRACE_LIMIT=3 - -# export PYTHONPATH=. # this is for running researcher branch; diff --git a/.env.example b/.env.example deleted file mode 100644 index d11e7eae..00000000 --- a/.env.example +++ /dev/null @@ -1,61 +0,0 @@ -""" -This file is a template for the .env file. - -Please copy this file to .env and fill in the values. - -For more information about configuration options, please refer to the documentation - -""" - -# ========================================== -# Global configs: -MAX_RETRY=10 -RETRY_WAIT_SECONDS=20 -# ========================================== - - -# ========================================== -# Backend Configuration -# ========================================== -# BACKEND=rdagent.oai.backend.LiteLLMAPIBackend -# ========================================== - -# ========================================== -# Backend Configuration (choose one) -# ========================================== - -# 1. Set universal API key -# CHAT_MODEL="gpt-4o" -# EMBEDDING_MODEL="text-embedding-3-small" -# OPENAI_API_BASE="https://your-endpoint.com/v1" -# OPENAI_API_KEY="sk-your-api-key-here" - -# 2. Set separate API KEY -# Chat configuration -OPENAI_API_KEY="sk-chat-key" -OPENAI_API_BASE="https://xxx-litellm.com/v1" -CHAT_MODEL='gpt-4o' - -# Embedding configuration (using other service) -# Use siliconflow as example, pay attention to the litellm_proxy prefix -LITELLM_PROXY_API_KEY="sk-embedding-service-key" -LITELLM_PROXY_API_BASE="https://api.siliconflow.cn/v1" -EMBEDDING_MODEL="litellm_proxy/BAAI/bge-large-en-v1.5" -# ========================================== - -# ========================================== -# Other Configuration -# ========================================== -# CHAT_AZURE_API_BASE= -# CHAT_AZURE_API_VERSION= - -# EMBEDDING_AZURE_API_BASE= -# EMBEDDING_AZURE_API_VERSION= - -# Cache Setting (Optional): -# USE_CHAT_CACHE=True -# USE_EMBEDDING_CACHE=True -# FT_DOCKER_ENABLE_CACHE=True -# DS_DOCKER_ENABLE_CACHE=True -# Senario Configs: -# ========================================== \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index f30443ba..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -github: - - MIIC-finance diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index bde7b40e..00000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: "\U0001F41B Bug Report" -about: Submit a bug report to help us improve RD-Agent -labels: bug - ---- - -## ๐Ÿ› Bug Description - - - -## To Reproduce - -Steps to reproduce the behavior: - -1. -2. -3. - - -## Expected Behavior - - - -## Screenshot - - - -## Environment - -**Note**: Users can run `rdagent collect_info` to get system information and paste it directly here. - - - Name of current operating system: - - Processor architecture: - - System, version, and hardware information: - - Version number of the system: - - Python version: - - Container ID: - - Container Name: - - Container Status: - - Image ID used by the container: - - Image tag used by the container: - - Container port mapping: - - Container Label: - - Startup Commands: - - RD-Agent version: - - Package version: - -## Additional Notes - - diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md deleted file mode 100644 index 96c70e5a..00000000 --- a/.github/ISSUE_TEMPLATE/documentation.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: "\U0001F4D6 Documentation" -about: Report an issue related to documentation - ---- - -## ๐Ÿ“– Documentation - - diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index ef239738..00000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: "\U0001F31FFeature Request" -about: Request for a new RD-Agent feature -labels: enhancement - ---- - -## ๐ŸŒŸ Feature Description - - -## Motivation - -1. Application scenario -2. Related works (Papers, Github repos etc.): -3. Any other relevant and important information: - - - -## Alternatives - - - -## Additional Notes - - diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index 4b92da90..00000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: "โ“Questions & Help" -about: Have some questions? We can offer help. -labels: question - ---- - -## โ“ Questions and Help - -We sincerely suggest you to carefully read the [documentation](http://rdagent.readthedocs.io/). After that, if you still feel puzzled, please describe the question clearly under this issue. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index cae5ec0d..00000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - -## Description - - -## Motivation and Context - - - -## How Has This Been Tested? - -- [ ] If you are adding a new feature, test on your own test scripts. - - - -## Screenshots of Test Results (if appropriate): -1. Your own tests: - -## Types of changes - -- [ ] Fix bugs -- [ ] Add new feature -- [ ] Update documentation diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index cd494ea0..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,19 +0,0 @@ -updates: - - commit-message: - prefix: build(actions) - directory: / - package-ecosystem: github-actions - schedule: - interval: weekly - - commit-message: - prefix: build(requirements) - directory: / - groups: - dev: - dependency-type: development - prod: - dependency-type: production - package-ecosystem: pip - schedule: - interval: weekly -version: 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index c97d541d..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,69 +0,0 @@ -concurrency: - cancel-in-progress: true - group: ${{ github.workflow }}-${{ github.ref }} -jobs: - ci: - if: ${{ !cancelled() && ! failure() }} - needs: dependabot - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - cache: pip - python-version: ${{ matrix.python-version }} - - run: make dev - - name: lint test docs and build - run: make lint docs-gen test-offline # test docs build - strategy: - matrix: - python-version: - - '3.10' - - '3.11' - dependabot: - if: ${{ github.actor == 'dependabot[bot]' && startsWith(github.head_ref, 'dependabot/pip/') }} - permissions: - contents: write - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.head_ref }} - - name: Set up Git - run: | - git config --global user.name github-actions - git config --global user.email github-actions@github.com - - name: Set up Python with multiple versions. - uses: actions/setup-python@v5 - with: - cache: pip - python-version: | - 3.10 - 3.11 - - name: Install pipenv using pipx - run: pipx install pipenv - - name: Generate constraints for all supported Python versions - run: | - CI= PYTHON_VERSION=3.10 make constraints - CI= PYTHON_VERSION=3.11 make constraints - - name: Push changes if applicable - run: | - if [[ -n `git status --porcelain` ]]; then - git commit -a -m "build: Update constraints for dependabot." - git push - fi -name: CI -on: - pull_request: - types: - - opened - - synchronize - push: - branches: - - main diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml deleted file mode 100644 index 8c9cd8ff..00000000 --- a/.github/workflows/pr.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Lint pull request title - -on: - pull_request: - types: - - opened - - synchronize - - reopened - - edited - -concurrency: - cancel-in-progress: true - group: ${{ github.workflow }}-${{ github.ref }} - -jobs: - lint-title: - runs-on: ubuntu-latest - steps: - # This step is necessary because the lint title uses the .commitlintrc.js file in the project root directory. - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '16' - - - name: Install commitlint - run: npm install --save-dev @commitlint/{config-conventional,cli} - - - name: Validate PR Title with commitlint - env: - BODY: ${{ github.event.pull_request.title }} - run: | - echo "$BODY" | npx commitlint --config .commitlintrc.js diff --git a/.github/workflows/readthedocs-preview.yml b/.github/workflows/readthedocs-preview.yml deleted file mode 100644 index e3999a3c..00000000 --- a/.github/workflows/readthedocs-preview.yml +++ /dev/null @@ -1,17 +0,0 @@ -concurrency: - cancel-in-progress: true - group: ${{ github.workflow }}-${{ github.ref }} -jobs: - documentation-links: - runs-on: ubuntu-latest - steps: - - uses: readthedocs/actions/preview@v1 - with: - project-slug: RDAgent -name: Read the Docs Pull Request Preview -on: - pull_request_target: - types: - - opened -permissions: - pull-requests: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 3b59cd1d..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Release -on: - push: - branches: - - main -permissions: - contents: read -jobs: - release_and_publish: - permissions: - contents: write - pull-requests: read - runs-on: ubuntu-latest - steps: - - name: Release please - id: release_please - uses: googleapis/release-please-action@v4 - with: - # The current PAT (personal access token) was created on 2024-08-05, - # since the maximum validity of PAT is 1 year, you need to change the PAT before 2025-08-05. - token: ${{ secrets.PAT }} - release-type: simple - - uses: actions/checkout@v4 - if: ${{ steps.release_please.outputs.release_created }} - with: - fetch-depth: 0 - - name: Set up Python - if: ${{ steps.release_please.outputs.release_created }} - uses: actions/setup-python@v5 - with: - cache: pip - python-version: '3.10' - - name: Install dependencies - if: ${{ steps.release_please.outputs.release_created }} - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine # better-exceptions(optional for debug) - - run: make dev - if: ${{ steps.release_please.outputs.release_created }} - - run: make build - if: ${{ steps.release_please.outputs.release_created }} - - name: upload - if: ${{ steps.release_please.outputs.release_created }} - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - make upload diff --git a/.gitignore b/.gitignore index 6ff575f7..c64bf607 100644 --- a/.gitignore +++ b/.gitignore @@ -1,190 +1,12 @@ -# Custom -*.swp -.DS_Store -Pipfile -public -release-notes.md -typescript* -tmp/ -.ai/ - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -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 - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -/log*/ -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.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 - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env* -*.env -.venv -^env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# all pkl files -*.pkl - -# all h5 files -*.h5 - -# all vs-code files -.vscode/ - -# reports -reports/ - -# git_ignore_folder +.env +.env.backup* +*.backup* git_ignore_folder/ - -#cache -*cache*/ -*cache.json - -# DB files -*.db - -# Docker -factor_template/mlruns/ -env_tpl -mlruns/ - -# possible output from coder or runner -*.pth -*qlib_res.csv - -# shell script -*.out -/*.sh -.aider* -rdagent/app/benchmark/factor/example.json - -# UI Server resources -videos/ -static/ - -# AI assistant -.cursor/ -.claude/ -AGENTS.md -!rdagent/**/AGENTS.md - -scripts/ +fin_quant.log +selector.log +pickle_cache/ +log/ +__pycache__/ +*.pyc +*.pyo +prompt_cache.db diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index dcfcddb1..00000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# .readthedocs.yml -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - -# Required -version: 2 - -# Set the version of Python and other tools you might need -build: - os: ubuntu-22.04 - tools: - python: "3.10" - # During the build process, you need to fetch tags, and since the default command to read the docs only pulls shallow code, it will cause an error. - # So we added the `git fetch --tags --unshallow || true` command to fetch the full tag record. - # Adding this command overrides the default command, so we copied it over to make sure the build was successful. - commands: - - python -mvirtualenv $READTHEDOCS_VIRTUALENV_PATH - - python -m pip install --upgrade --no-cache-dir pip setuptools - - python -m pip install --upgrade --no-cache-dir sphinx - - python -m pip install --exists-action=w --no-cache-dir -r requirements/docs.txt - - python -m pip install --upgrade --upgrade-strategy only-if-needed --no-cache-dir . - - git fetch --tags --unshallow || true - - mkdir -p $READTHEDOCS_OUTPUT/html/ - - python -m sphinx -T -b html -d _build/doctrees -D language=en ./docs $READTHEDOCS_OUTPUT/html - -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py - -# Build all formats -formats: all - -# Optionally set the version of Python and requirements required to build your docs -python: - install: - - requirements: requirements/docs.txt - - method: pip - path: . diff --git a/.streamlit/config.toml b/.streamlit/config.toml deleted file mode 100644 index 74812cd0..00000000 --- a/.streamlit/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[client] -showSidebarNavigation = false \ No newline at end of file diff --git a/LICENSE b/LICENSE index 9e841e7a..f36a7b77 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,662 @@ - MIT License + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 - Copyright (c) Microsoft Corporation. + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: + Preamble - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + + Copyright (C) {{ year }} {{ organization }} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/rdagent/app/qlib_rd_loop/prompts.yaml b/rdagent/app/qlib_rd_loop/prompts.yaml index 129c2c09..18c570fc 100644 --- a/rdagent/app/qlib_rd_loop/prompts.yaml +++ b/rdagent/app/qlib_rd_loop/prompts.yaml @@ -1,15 +1,55 @@ hypothesis_generation: system: |- - You are an expert in financial analysis. Your task is to generate a well-reasoned hypothesis based on the provided financial factors and report content. - Please ensure your response is in JSON format as shown below: + You are an expert quantitative researcher specialized in FX (foreign exchange) trading, + specifically EURUSD intraday strategies on 15-minute bars. + + EURUSD domain knowledge you must apply: + - London session (08:00-12:00 UTC): highest volatility, trending behavior โ€” favor momentum strategies + - NY session (13:00-17:00 UTC): second volatility peak, also trending + - Asian session (00:00-07:00 UTC): low volatility, mean-reverting behavior + - London/NY overlap (13:00-17:00 UTC): strongest directional moves of the day + - Weekend gap risk: avoid holding positions after Friday 20:00 UTC + - Spread cost: ~1.5 bps per trade โ€” strategies must minimize unnecessary entries + - EURUSD is mean-reverting on short windows (<1h), trending on longer (>4h) + - Key macro drivers: ECB/Fed rate decisions, NFP (first Friday of month), CPI releases + + Available model types you can propose: + - TimeSeries: LSTM, GRU, TCN (Temporal Convolutional Network), Transformer, PatchTST + - Tabular: XGBoost, LightGBM, RandomForest (on engineered features) + - Hybrid: CNN+LSTM, XGBoost+LSTM ensemble + - Statistical: Regime-switching (HMM), Kalman filter + + Available features in the dataset: + - OHLCV: open, high, low, close, volume (15min bars) + - Returns: ret_1, ret_4, ret_8, ret_16, ret_96 + - Technical: rsi_14, macd_hist, adx_14, atr_14, bb_pct, stoch_k, cci_14 + - Volatility: vol_real_4, vol_real_16, vol_ratio, zscore_ret_96 + - Time/Session: hour, is_london, is_ny, is_overlap, hour_sin, hour_cos + - Lags: rsi_14_lag1-8, macd_hist_lag1-8, bb_pct_lag1-8 + + Your hypothesis must: + 1. Specify which session(s) the strategy targets + 2. Name which model type to use and why it fits EURUSD + 3. Include a session filter (is_london / is_ny) + 4. Include a spread filter (only trade when expected |return| > 0.0003) + 5. Specify target: classification (fwd_sign_4) or regression (fwd_ret_4) + + Please ensure your response is in JSON format: { - "hypothesis": "A clear and concise hypothesis based on the provided information.", - "reason": "A detailed explanation supporting the generated hypothesis.", + "hypothesis": "A clear and concise trading hypothesis for EURUSD 15min.", + "reason": "Detailed explanation including session, model choice, and expected edge.", + "model_type": "One of: TimeSeries / Tabular / XGBoost", + "target_session": "london / ny / asian / all", + "expected_arr_range": "e.g. 8-12%" } user: |- - The following are the financial factors and their descriptions: + Previously tried approaches and their results: {{ factor_descriptions }} - The report content is as follows: - {{ report_content }} \ No newline at end of file + Additional context: + {{ report_content }} + + Generate a NEW hypothesis that is meaningfully different from what has been tried. + Focus on approaches that have NOT been tested yet. + Target: beat current best ARR of 9.62%. diff --git a/rdagent/components/coder/model_coder/prompts.yaml b/rdagent/components/coder/model_coder/prompts.yaml index 20e5462a..90974b8a 100644 --- a/rdagent/components/coder/model_coder/prompts.yaml +++ b/rdagent/components/coder/model_coder/prompts.yaml @@ -31,6 +31,15 @@ evolving_strategy_model_coder: system: |- User is trying to implement some pytorch models in the following scenario: {{ scenario }} + + EURUSD-specific rules (ALWAYS apply these in generated code): + 1. Session filter: use is_london and is_ny columns โ€” weight/filter signals to active sessions + 2. Spread filter: only generate signal when abs(predicted_return) > 0.0003 + 3. ADX regime: if adx_proxy > 1.2 use trend model; if adx_proxy < 0.8 use mean-reversion + 4. Weekend filter: zero out signals when dayofweek==4 and hour>=20 + 5. Max trade frequency: target <15 trades per day (avoid spread cost death) + 6. Supported model_type values: "Tabular", "TimeSeries", "XGBoost" + Your code is expected to align the scenario in any form which means The user needs to get the prediction of the model based on the input data. To help you write the correct code, the user might provide multiple information that helps you write the correct code: diff --git a/rdagent/log/__init__.py b/rdagent/log/__init__.py deleted file mode 100644 index a0d37eff..00000000 --- a/rdagent/log/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from rdagent.log.logger import RDAgentLog -from rdagent.log.utils import LogColors - -rdagent_logger: RDAgentLog = RDAgentLog() diff --git a/rdagent/log/base.py b/rdagent/log/base.py deleted file mode 100644 index 7e0b96d8..00000000 --- a/rdagent/log/base.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -from abc import abstractmethod -from collections.abc import Generator -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Literal, Optional - - -@dataclass -class Message: - """The info unit of the storage""" - - tag: str # namespace like like a.b.c - level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] # The level of the logging - timestamp: datetime # The time when the message is generated - caller: Optional[ - str - ] # The caller of the logging like `rdagent.oai.llm_utils:_create_chat_completion_inner_function:55`(file:func:line) - pid_trace: Optional[str] # The process id trace; A-B-C represents A create B, B create C - content: object # The content - - -class Storage: - """ - Basic storage to support saving objects; - - # Usage: - - The storage has mainly two kind of users: - - The logging end: you can choose any of the following method to use the object - - We can use it directly with the native logging storage - - We can use it with other logging tools; For example, serve as a handler for loggers - - The view end: - - Mainly for the subclass of `logging.base.View` - - It should provide two kind of ways to provide content - - offline content provision. - - online content preovision. - """ - - @abstractmethod - def log( - self, - obj: object, - tag: str = "", - timestamp: datetime | None = None, - ) -> str | Path: - """ - - Parameters - ---------- - obj : object - The object for logging. - name : str - The name of the object. For example "a.b.c" - We may log a lot of objects to a same name - - Returns - ------- - str | Path - The storage identifier of the object. - """ - ... - - @abstractmethod - def iter_msg(self) -> Generator[Message, None, None]: - """ - Iterate the message in the storage. - """ - ... - - @abstractmethod - def truncate(self, time: datetime) -> None: - """ - Remove all log entries after the specified time. - """ - ... - - def __str__(self) -> str: - return self.__class__.__name__ - - -class View: - """ - Motivation: - - Display the content in the storage - """ - - # TODO: pleas fix me - @abstractmethod - def display(self, s: Storage, watch: bool = False) -> None: - """ - - Parameters - ---------- - s : Storage - - watch : bool - should we watch the new content and display them - """ - ... diff --git a/rdagent/log/conf.py b/rdagent/log/conf.py deleted file mode 100644 index a6fdfc3d..00000000 --- a/rdagent/log/conf.py +++ /dev/null @@ -1,34 +0,0 @@ -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from pydantic_settings import SettingsConfigDict - -from rdagent.core.conf import ExtendedBaseSettings - - -class LogSettings(ExtendedBaseSettings): - model_config = SettingsConfigDict(env_prefix="LOG_", protected_namespaces=()) - - trace_path: str = str(Path.cwd() / "log" / datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S-%f")) - - format_console: str | None = None - """"If it is None, leave it as the default""" - - ui_server_port: int | None = None - - storages: dict[str, list[int | str]] = {} - - def set_ui_server_port(self, port: int | None) -> None: - self.ui_server_port = port - if port is None: - self.storages.pop("rdagent.log.ui.storage.WebStorage", None) - return - - self.storages["rdagent.log.ui.storage.WebStorage"] = [port, self.trace_path] - - def model_post_init(self, _context: Any, /) -> None: - self.set_ui_server_port(self.ui_server_port) - - -LOG_SETTINGS = LogSettings() diff --git a/rdagent/log/logger.py b/rdagent/log/logger.py deleted file mode 100644 index faade38b..00000000 --- a/rdagent/log/logger.py +++ /dev/null @@ -1,153 +0,0 @@ -import os -import sys -from contextlib import contextmanager -from contextvars import ContextVar -from datetime import datetime -from pathlib import Path -from typing import Generator - -from loguru import logger -from psutil import Process - -from rdagent.core.utils import SingletonBaseClass, import_class - -from .base import Storage -from .conf import LOG_SETTINGS -from .storage import FileStorage -from .utils import get_caller_info - - -class RDAgentLog(SingletonBaseClass): - """ - The files are organized based on the tag & PID - Here is an example tag - - .. code-block:: - - a - - b - - c - - 123 - - common_logs.log - - 1322 - - common_logs.log - - 1233 - - .pkl - - d - - 1233-673 ... - - 1233-4563 ... - - 1233-365 ... - - """ - - # Thread-/coroutine-local tag; In Linux forked subprocess, it will be copied to the subprocess. - _tag_ctx: ContextVar[str] = ContextVar("_tag_ctx", default="") - _raw_log_key = "_rdagent_raw" - - @classmethod - def _configure_console_sinks(cls) -> None: - raw_filter = lambda record: bool(record["extra"].get(cls._raw_log_key, False)) - normal_filter = lambda record: not raw_filter(record) - - if LOG_SETTINGS.format_console is not None: - logger.add(sys.stdout, format=LOG_SETTINGS.format_console, filter=normal_filter) - else: - logger.add(sys.stdout, filter=normal_filter) - logger.add(sys.stdout, format="{message}", filter=raw_filter) - - @property - def _tag(self) -> str: # Get current tag - return self._tag_ctx.get() - - @_tag.setter # Set current tag - def _tag(self, value: str) -> None: - self._tag_ctx.set(value) - - def __init__(self) -> None: - logger.remove() - self._configure_console_sinks() - - self.storage = FileStorage(LOG_SETTINGS.trace_path) - self.other_storages: list[Storage] = [] - self.refresh_storages_from_settings() - - self.main_pid = os.getpid() - - def refresh_storages_from_settings(self) -> None: - self.other_storages = [] - for storage, args in LOG_SETTINGS.storages.items(): - storage_cls = import_class(storage) - self.other_storages.append(storage_cls(*args)) - - def rebind_console_to_current_streams(self) -> None: - """Rebind loguru sinks to the current stdio objects. - - This is needed in forked/spawned subprocesses after stdout/stderr have been - redirected, because loguru keeps references to the original stream objects. - """ - logger.remove() - self._configure_console_sinks() - - @contextmanager - def tag(self, tag: str) -> Generator[None, None, None]: - if tag.strip() == "": - raise ValueError("Tag cannot be empty.") - # Generate a new complete tag - current_tag = self._tag_ctx.get() - new_tag = tag if current_tag == "" else f"{current_tag}.{tag}" - # Set and save token for later restore - token = self._tag_ctx.set(new_tag) - try: - yield - finally: - # Restore previous tag (thread/coroutine safe) - self._tag_ctx.reset(token) - - def set_storages_path(self, path: str | Path) -> None: - if isinstance(path, str): - path = Path(path) - for storage in [self.storage] + self.other_storages: - if hasattr(storage, "path"): - storage.path = path - - def truncate_storages(self, time: datetime) -> None: - for storage in [self.storage] + self.other_storages: - storage.truncate(time=time) - - def get_pids(self) -> str: - """ - Returns a string of pids from the current process to the main process. - Split by '-'. - """ - pid = os.getpid() - process = Process(pid) - pid_chain = f"{pid}" - while process.pid != self.main_pid: - parent_pid = process.ppid() - parent_process = Process(parent_pid) - pid_chain = f"{parent_pid}-{pid_chain}" - process = parent_process - return pid_chain - - def log_object(self, obj: object, *, tag: str = "") -> None: - tag = f"{self._tag}.{tag}.{self.get_pids()}".strip(".") - - for storage in [self.storage] + self.other_storages: - storage.log(obj, tag=tag) - - def _log(self, level: str, msg: str, *, tag: str = "", raw: bool = False) -> None: - caller_info = get_caller_info(level=3) - tag = f"{self._tag}.{tag}.{self.get_pids()}".strip(".") - - patched_logger = logger.patch(lambda r: r.update(caller_info)).bind(**{self._raw_log_key: raw}).opt(raw=raw) - log_func = getattr(patched_logger, level) - log_func(msg) - - def info(self, msg: str, *, tag: str = "", raw: bool = False) -> None: - self._log("info", msg, tag=tag, raw=raw) - - def warning(self, msg: str, *, tag: str = "", raw: bool = False) -> None: - self._log("warning", msg, tag=tag, raw=raw) - - def error(self, msg: str, *, tag: str = "", raw: bool = False) -> None: - self._log("error", msg, tag=tag, raw=raw) diff --git a/rdagent/log/mle_summary.py b/rdagent/log/mle_summary.py deleted file mode 100644 index e6a62e24..00000000 --- a/rdagent/log/mle_summary.py +++ /dev/null @@ -1,260 +0,0 @@ -import pickle -import traceback -from collections import defaultdict -from pathlib import Path - -import fire -import pandas as pd - -from rdagent.core.experiment import FBWorkspace -from rdagent.core.proposal import ExperimentFeedback -from rdagent.log.storage import FileStorage -from rdagent.log.utils import extract_json, extract_loopid_func_name, is_valid_session -from rdagent.log.utils.folder import get_first_session_file_after_duration -from rdagent.scenarios.data_science.experiment.experiment import DSExperiment -from rdagent.scenarios.data_science.test_eval import ( - MLETestEval, - NoTestEvalError, - get_test_eval, -) - -# from rdagent.scenarios.kaggle.kaggle_crawler import score_rank -from rdagent.utils.workflow import LoopBase - - -def save_grade_info(log_trace_path: Path): - test_eval = get_test_eval() - - trace_storage = FileStorage(log_trace_path) - for msg in trace_storage.iter_msg(tag="competition"): - competition = msg.content - - for msg in trace_storage.iter_msg(tag="running"): - if isinstance(msg.content, DSExperiment): - # TODO: mle_score.txt is not a general name now. - # Please use a more general name like test_score.txt - try: - mle_score_str = test_eval.eval(competition, msg.content.experiment_workspace) - trace_storage.log( - mle_score_str, tag=f"{msg.tag}.mle_score.pid", save_type="pkl", timestamp=msg.timestamp - ) - except Exception as e: - print(f"Error in {log_trace_path}: {e}", traceback.format_exc()) - - -def save_all_grade_info(log_folder: str | Path) -> None: - for log_trace_path in Path(log_folder).iterdir(): - if is_valid_session(log_trace_path): - try: - save_grade_info(log_trace_path) - except NoTestEvalError as e: - print(f"Error in {log_trace_path}: {e}", traceback.format_exc()) - - -def _get_loop_and_fn_after_hours(log_folder: Path, hours: int): - stop_session_fp = get_first_session_file_after_duration(log_folder, f"{hours}h") - - with stop_session_fp.open("rb") as f: - session_obj: LoopBase = pickle.load(f) - - loop_trace = session_obj.loop_trace - stop_li = max(loop_trace.keys()) - last_loop = loop_trace[stop_li] - last_step = last_loop[-1] - stop_fn = session_obj.steps[last_step.step_idx] - print(f"Stop Loop: {stop_li=}, {stop_fn=}") - files = sorted( - (log_folder / "__session__").glob("*/*_*"), key=lambda f: (int(f.parent.name), int(f.name.split("_")[0])) - ) - - print(f"Max Session: {files[-1:]=}") - return stop_li, stop_fn - - -def summarize_folder(log_folder: Path, hours: int | None = None) -> None: - test_eval = get_test_eval() - - is_mle = isinstance(test_eval, MLETestEval) - """ - Summarize the log folder and save the summary as a pickle file. - Args: - log_folder (Path): The path to the log folder (contains many log traces). - hours (int | None): The number of hours to stat. If None, stat all. - """ - log_folder = Path(log_folder) - stat = defaultdict(dict) - for log_trace_path in log_folder.iterdir(): # One log trace - if not is_valid_session(log_trace_path): - continue - loop_num = 0 - made_submission_num = 0 - valid_submission_num = 0 - above_median_num = 0 - get_medal_num = 0 - bronze_num = 0 - silver_num = 0 - gold_num = 0 - test_scores = {} - test_ranks = {} - valid_scores = {} - bronze_threshold = 0.0 - silver_threshold = 0.0 - gold_threshold = 0.0 - median_threshold = 0.0 - success_loop_num = 0 - - sota_exp_stat = "" - sota_exp_score = None - sota_exp_rank = None - grade_output = None - - if hours: - stop_li, stop_fn = _get_loop_and_fn_after_hours(log_trace_path, hours) - msgs = [(msg, extract_loopid_func_name(msg.tag)) for msg in FileStorage(log_trace_path).iter_msg()] - msgs = [(msg, int(loop_id) if loop_id else loop_id, fn) for msg, (loop_id, fn) in msgs] - msgs.sort(key=lambda m: m[1] if m[1] else -1) # sort by loop id - for msg, loop_id, fn in msgs: # messages in log trace - if loop_id: - loop_num = max(loop_id + 1, loop_num) - if hours and loop_id == stop_li and fn == stop_fn: - break - if msg.tag and "llm" not in msg.tag and "session" not in msg.tag: - if "competition" in msg.tag: - stat[log_trace_path.name]["competition"] = msg.content - - # get threshold scores - workflowexp = FBWorkspace() - if is_mle: - stdout = workflowexp.execute( - env=test_eval.env, - entry=f"mlebench grade-sample None {stat[log_trace_path.name]['competition']} --data-dir /mle/data", - ) - grade_output = extract_json(stdout) - if grade_output: - bronze_threshold = grade_output["bronze_threshold"] - silver_threshold = grade_output["silver_threshold"] - gold_threshold = grade_output["gold_threshold"] - median_threshold = grade_output["median_threshold"] - - if "running" in msg.tag: - if isinstance(msg.content, DSExperiment): - if msg.content.result is not None: - valid_scores[loop_id] = msg.content.result - elif "mle_score" in msg.tag: - grade_output = extract_json(msg.content) - if grade_output: - if grade_output["submission_exists"]: - made_submission_num += 1 - if grade_output["score"] is not None: - test_scores[loop_id] = grade_output["score"] - # if is_mle: - # _, test_ranks[loop_id] = score_rank( - # stat[log_trace_path.name]["competition"], grade_output["score"] - # ) - if grade_output["valid_submission"]: - valid_submission_num += 1 - if grade_output["above_median"]: - above_median_num += 1 - if grade_output["any_medal"]: - get_medal_num += 1 - if grade_output["bronze_medal"]: - bronze_num += 1 - if grade_output["silver_medal"]: - silver_num += 1 - if grade_output["gold_medal"]: - gold_num += 1 - - if "feedback" in msg.tag and "evolving" not in msg.tag: - if isinstance(msg.content, ExperimentFeedback) and bool(msg.content): - success_loop_num += 1 - - if grade_output: # sota exp's grade output - if grade_output["gold_medal"]: - sota_exp_stat = "gold" - elif grade_output["silver_medal"]: - sota_exp_stat = "silver" - elif grade_output["bronze_medal"]: - sota_exp_stat = "bronze" - elif grade_output["above_median"]: - sota_exp_stat = "above_median" - elif grade_output["valid_submission"]: - sota_exp_stat = "valid_submission" - elif grade_output["submission_exists"]: - sota_exp_stat = "made_submission" - if grade_output["score"] is not None: - sota_exp_score = grade_output["score"] - # if is_mle: - # _, sota_exp_rank = score_rank( - # stat[log_trace_path.name]["competition"], grade_output["score"] - # ) - - stat[log_trace_path.name].update( - { - "loop_num": loop_num, - "made_submission_num": made_submission_num, - "valid_submission_num": valid_submission_num, - "above_median_num": above_median_num, - "get_medal_num": get_medal_num, - "bronze_num": bronze_num, - "silver_num": silver_num, - "gold_num": gold_num, - "test_scores": test_scores, - # "test_ranks": test_ranks, - "valid_scores": valid_scores, - "success_loop_num": success_loop_num, - "sota_exp_stat": sota_exp_stat, - "sota_exp_score": sota_exp_score, - # "sota_exp_rank": sota_exp_rank, - "bronze_threshold": bronze_threshold, - "silver_threshold": silver_threshold, - "gold_threshold": gold_threshold, - "median_threshold": median_threshold, - } - ) - - # Save the summary - save_name = f"summary_{hours}h.pkl" if hours else "summary.pkl" - save_p = log_folder / save_name - if save_p.exists(): - save_p.unlink() - print(f"Old {save_name} removed.") - pd.to_pickle(stat, save_p) - - -# { -# "competition_id": "stanford-covid-vaccine", -# "score": null, -# "gold_threshold": 0.34728, -# "silver_threshold": 0.35175, -# "bronze_threshold": 0.3534, -# "median_threshold": 0.363095, -# "any_medal": false, -# "gold_medal": false, -# "silver_medal": false, -# "bronze_medal": false, -# "above_median": false, -# "submission_exists": true, -# "valid_submission": false, -# "is_lower_better": true, -# "created_at": "2025-01-21T11:59:33.788201", -# "submission_path": "submission.csv" -# } - - -def grade_summary(log_folder: str) -> None: - """ - Generate test scores for log traces in the log folder and save the summary. - """ - log_folder = Path(log_folder) - save_all_grade_info(log_folder) - summarize_folder(log_folder) - - -if __name__ == "__main__": - fire.Fire( - { - "grade": save_all_grade_info, - "summary": summarize_folder, - "grade_summary": grade_summary, - } - ) diff --git a/rdagent/log/server/README.md b/rdagent/log/server/README.md deleted file mode 100644 index 19b55649..00000000 --- a/rdagent/log/server/README.md +++ /dev/null @@ -1,253 +0,0 @@ -# API - -## A. Controls - -### 1. /upload [POST] - -#### Request - -- "scenario": one of six values - 1. "Finance Data Building" - 2. "Finance Data Building (Reports)" - 3. "Finance Model Implementation" - 4. "General Model Implementation" - 5. "Medical Model Implementation" - 6. "Data Science" -- "files": **2** scenarios need this - 1. in "Finance Data Building (Reports)" Scenario, one or more pdf files. - 2. in "General Model Implementation" Scenario, one pdf file or one pdf link like `https://arxiv.org/pdf/2210.09789` -- "competition": **Data Science** Scenario need this, one of 75 competitions. -- "loops": Number of loops after which RD-Agent will automatically stop (optional; if not set, it will not stop automatically and must be stopped manually). -- "all_duration": Total duration (in hours) for which the RD-Agent should run before stopping automatically. If not set, the agent will continue running until stopped manually or by the "loops" parameter. - -#### Response - -- "id": a unique identifier string, such as `/home/rdagent_log/data_science/competition_A/trace_1` or `/home/rdagent_log/finance/trace_1`, used to mark the series of logs generated by this RD-Agent run. - -### 2. /control [POST] - -#### Request - -- "id": identifier -- "action": one of three values - 1. "pause" - 2. "resume" - 3. "stop" - -#### Response - -- "status": "success" / "error: ..." - -### 3. /trace [POST] - -Returns the sequence of Messages generated for the current id on the backend that **have not yet been returned to the frontend**. - -#### Request - -- "id": identifier -- "all": True / False. True means all Messages not yet provided to the frontend will be returned; False returns a random 1 to 10 Messages. In most cases, this should be True. -- "reset": True / False. Reset means the pointer for "not yet returned to the frontend" will be set back to the first Message generated for this id, i.e., return from the beginning. In most cases, this should be False. - -#### Response - -- a list of [Messages](#b-messages) - -## B. Messages - -### Research - -Only **2** Message in one loop - -1. hypothesis - -```json -{ - "tag": "research.hypothesis", - "timestamp": "", - "loop_id": "1", - "content": { - "hypothesis": "...", - "reason": "...", - "component": "...", // only exists in Data Science Scenario - "concise_reason": "...", - "concise_justification": "...", - "concise_observation": "...", - "concise_knowledge": "...", - } -} -``` - -2. tasks - -```json -{ - "tag": "research.tasks", - "timestamp": "", - "loop_id": "1", - "content": [ // list of tasks - { - "name": "...", - "description": "...", - "model_type": "...", // only exists in "Finance Model Implementation", "General Model Implementation", "Medical Model Implementation", or some tasks of "Data Science" - "architecture": "...", // same as above - "hyperparameters": "...", // same as above - }, - { - - } - //... same as above - ] -} -``` - -### evolving - -- 1 to 10 pairs of Messages (codes & feedbacks), each identified by an "evo_id" indicating the evolving round. -- In the **Data Science** scenario, each evolving round contains only **one task**, but the "codes" for that task may include **multiple code files**. -- In other scenarios, each evolving round may contain **multiple tasks**, but each task's "codes" will include only **one code file**. - -1. codes - -```json -{ - "tag": "evolving.codes", - "timestamp": "", - "loop_id": "1", - "evo_id": "0", - "content": [ // list of task_name & codes - { - "evo_id": "0", - "target_task_name": "task_1", - "workspace": { // one or more codes - "a.py": "...", - "b.py": "...", - //... - } - }, - { - "evo_id": "0", - "target_task_name": "task_2", - "workspace": { - "a.py": "...", - //... - } - } - //... same as above - ] -} - -{ - "tag": "evolving.codes", - "timestamp": "", - "loop_id": "1", - "evo_id": "1", - "content": [ - //... same as above - ] -} -``` - -2. feedbacks - -```json -{ - "tag": "evolving.feedbacks", - "timestamp": "", - "loop_id": "1", - "evo_id": "0", - "content": [ // list of feedbacks - { - "evo_id": "0", - "final_decision": "True", // True or False - "execution": "...", - "code": "...", - "return_checking": "..." - }, - //... same as above - ] -} - -{ - "tag": "evolving.codes", - "timestamp": "", - "loop_id": "1", - "evo_id": "1", - "content": [ - //... same as above - ] -} -``` - -### feedback - -Each tag below appears only once per loop. - -1. config (only exists in "Finance Data Building"/"Finance Data Building (Reports)"/"Finance Model Implementation") - -```json -{ - "tag": "feedback.config", - "timestamp": "", - "loop_id": "1", - "content": { - "config": "a markdown string", - } -} -``` - -2. return_chart (only exists in "Finance Data Building"/"Finance Data Building (Reports)"/"Finance Model Implementation") - -```json -{ - "tag": "feedback.return_chart", - "timestamp": "", - "loop_id": "1", - "content": { - "chart_html": "chart html codes string", - } -} -``` - -3. metric - -```json -{ - "tag": "feedback.metric", - "timestamp": "", - "loop_id": "1", - "content": { - "result": "{ \"\": , ... }" // A JSON string containing metric names and their corresponding values. - } -} -``` - -4. hypothesis_feedback - -```json -{ - "tag": "feedback.hypothesis_feedback", - "timestamp": "", - "loop_id": "1", - "content": { - "decision": "True", - "reason": "...", - "exception": "...", - "observations": "...", // may not exists - "hypothesis_evaluation": "...", // may not existsc - "new_hypothesis": "...", // may not exists - } -} -``` - -# TODO - -## Session - -- How to continue. -- show & copy trace_id(name)? -- - -## Page - -1. remove Medical, add Finance Whole Pipeline -2. \ No newline at end of file diff --git a/rdagent/log/server/app.py b/rdagent/log/server/app.py deleted file mode 100644 index 575f14bc..00000000 --- a/rdagent/log/server/app.py +++ /dev/null @@ -1,562 +0,0 @@ -import logging -import os -import random -import traceback -from collections import defaultdict -from contextlib import redirect_stderr, redirect_stdout -from datetime import datetime, timezone -from multiprocessing import Process, Queue -from pathlib import Path -from queue import Empty - -import randomname -import typer -from flask import Flask, jsonify, request, send_file, send_from_directory -from flask_cors import CORS -from werkzeug.utils import secure_filename - -from rdagent.log.storage import FileStorage -from rdagent.log.ui.conf import UI_SETTING -from rdagent.log.ui.storage import WebStorage - -app = Flask(__name__, static_folder=str(Path(UI_SETTING.static_path).resolve())) -CORS(app) -app.config["UI_SERVER_PORT"] = 19899 - -_YELLOW = "\033[33m" -_RESET = "\033[0m" - - -class _YellowWarningFormatter(logging.Formatter): - def format(self, record: logging.LogRecord) -> str: - if record.levelno == logging.WARNING: - record.levelname = f"{_YELLOW}{record.levelname}{_RESET}" - return super().format(record) - - -def _configure_app_logger() -> None: - formatter = _YellowWarningFormatter( - fmt="[%(asctime)s] %(levelname)s in %(module)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - for handler in app.logger.handlers: - handler.setFormatter(formatter) - - -_configure_app_logger() - - -_TARGETS_WITHOUT_USER_INTERACTION = {"general_model", "fin_factor_report"} - - -class RDAgentTask: - def __init__( - self, - target_name: str, - kwargs: dict, - stdout_path: str, - log_trace_path: str, - scenario: str, - trace_name: str, - ui_server_port: int | None = None, - create_process: bool = True, - ) -> None: - self.target_name = target_name - self.kwargs = kwargs - self.stdout_path = stdout_path - self.log_trace_path = log_trace_path - self.scenario = scenario - self.trace_name = trace_name - self.ui_server_port = ui_server_port - self.process: Process | None = None - - # Two IPC queues for user interaction. - # - `user_request_q`: rdagent subprocess -> server (dicts to render on frontend) - # - `user_response_q`: server -> rdagent subprocess (user input dicts) - # NOTE: Use multiprocessing.Queue because rdagent is started as a separate process. - self.user_request_q: Queue = Queue(maxsize=1024) - self.user_response_q: Queue = Queue(maxsize=1024) - - if create_process: - self.process = Process( - target=self._run, - name=f"rdagent:{self.scenario}:{self.trace_name}", - ) - self.messages: list[dict] = [] - self.pointers: defaultdict[str, int] = defaultdict(int) - - def start(self) -> None: - if self.process is not None: - self.process.start() - - def is_alive(self) -> bool: - return self.process is not None and self.process.is_alive() - - def get_end_code(self) -> int: - if self.process is None or self.process.exitcode is None: - return 0 - return self.process.exitcode - - def stop(self) -> None: - if self.process is not None and self.process.is_alive(): - self.process.terminate() - self.process.join() - - # Best-effort cleanup for IPC queues. - for q in (self.user_request_q, self.user_response_q): - try: - q.cancel_join_thread() - except Exception: - pass - try: - q.close() - except Exception: - pass - - def _run(self) -> None: - from rdagent.log.conf import LOG_SETTINGS - - LOG_SETTINGS.set_ui_server_port(self.ui_server_port) - - from rdagent.log import rdagent_logger - - rdagent_logger.refresh_storages_from_settings() - rdagent_logger.set_storages_path(self.log_trace_path) - Path(self.stdout_path).parent.mkdir(parents=True, exist_ok=True) - with open(self.stdout_path, "w") as log_file: - with redirect_stdout(log_file), redirect_stderr(log_file): - rdagent_logger.rebind_console_to_current_streams() - try: - # Only interactive targets should receive IPC queues. - if self.target_name not in _TARGETS_WITHOUT_USER_INTERACTION: - self.kwargs.setdefault( - "user_interaction_queues", - (self.user_request_q, self.user_response_q), - ) - - if self.target_name == "data_science": - from rdagent.app.data_science.loop import main as data_science - - data_science(**self.kwargs) - elif self.target_name == "general_model": - from rdagent.app.general_model.general_model import ( - extract_models_and_implement as general_model, - ) - - general_model(**self.kwargs) - elif self.target_name == "fin_factor": - from rdagent.app.qlib_rd_loop.factor import main as fin_factor - - fin_factor(**self.kwargs) - elif self.target_name == "fin_factor_report": - from rdagent.app.qlib_rd_loop.factor_from_report import ( - main as fin_factor_report, - ) - - fin_factor_report(**self.kwargs) - elif self.target_name == "fin_model": - from rdagent.app.qlib_rd_loop.model import main as fin_model - - fin_model(**self.kwargs) - elif self.target_name == "fin_quant": - from rdagent.app.qlib_rd_loop.quant import main as fin_quant - - fin_quant(**self.kwargs) - else: - raise ValueError(f"Unknown target: {self.target_name}") - except Exception: - traceback.print_exc() - - -rdagent_processes: dict[str, RDAgentTask] = {} -log_folder_path = Path(UI_SETTING.trace_folder).absolute() - - -def _drain_user_requests_into_messages(task: RDAgentTask) -> None: - """Move a single pending user-interaction request into `task.messages`. - - Assumption: each rdagent process only has one active request at a time. - """ - - try: - req = task.user_request_q.get_nowait() - except Empty: - return - except Exception: - return - - # Standardize the message shape for the frontend. - # The agent can send either a full message dict, or a raw content dict. - if isinstance(req, dict) and {"tag", "timestamp", "content"}.issubset(req.keys()): - msg = req - else: - msg = { - "tag": "user_interaction.request", - "timestamp": datetime.now(timezone.utc).isoformat(), - "content": req, - } - task.messages.append(msg) - - -@app.route("/favicon.ico") -def favicon(): - return send_from_directory(app.static_folder, "favicon.ico", mimetype="image/vnd.microsoft.icon") - - -def _normalize_static_request_path(fn: str) -> str: - static_prefix = UI_SETTING.static_path.strip("./") - if static_prefix and fn.startswith(f"{static_prefix}/"): - return fn[len(static_prefix) + 1 :] - return fn - - -def _get_or_create_task(trace_id: str) -> RDAgentTask: - task = rdagent_processes.get(trace_id) - if task is None: - task = RDAgentTask( - target_name="", - kwargs={}, - stdout_path="", - log_trace_path=trace_id, - scenario="", - trace_name="", - ui_server_port=None, - create_process=False, - ) - rdagent_processes[trace_id] = task - return task - - -def _resolve_stdout_path(trace_id: str) -> Path | None: - normalized_trace_id = str(trace_id or "").strip() - if not normalized_trace_id: - return None - - task = rdagent_processes.get(str(log_folder_path / normalized_trace_id)) - if task is None or not task.stdout_path: - return None - - stdout_path = Path(task.stdout_path).resolve() - - try: - if os.path.commonpath([str(stdout_path), str(log_folder_path)]) != str(log_folder_path): - return None - except ValueError: - return None - - return stdout_path - - -def read_trace(log_path: Path, id: str = "") -> None: - fs = FileStorage(log_path) - ws = WebStorage(port=1, path=log_path) - task = _get_or_create_task(id) - task.messages = [] - last_timestamp = None - for msg in fs.iter_msg(): - data = ws._obj_to_json(obj=msg.content, tag=msg.tag, id=id, timestamp=msg.timestamp.isoformat()) - if data: - if isinstance(data, list): - for d in data: - task.messages.append(d["msg"]) - last_timestamp = msg.timestamp - else: - task.messages.append(data["msg"]) - last_timestamp = msg.timestamp - - now = datetime.now(timezone.utc) - if last_timestamp and (now - last_timestamp).total_seconds() > 1800: - task.messages.append( - { - "tag": "END", - "timestamp": now.isoformat(), - "content": {"error_msg": "Trace session has ended.", "end_code": 0}, - } - ) - - -# load all traces from the log folder -# for p in log_folder_path.glob("*/*/"): -# read_trace(p, id=str(p)) - - -@app.route("/trace", methods=["POST"]) -def update_trace(): - data = request.get_json() - trace_id = data.get("id") - return_all = data.get("all") - reset = data.get("reset") - msg_num = random.randint(1, 10) - app.logger.info(data) - log_folder_path = Path(UI_SETTING.trace_folder).absolute() - if not trace_id: - return jsonify({"error": "Trace ID is required"}), 400 - trace_id = str(log_folder_path / trace_id) - - task = _get_or_create_task(trace_id) - - # Make sure any pending user-interaction requests are visible to the frontend. - _drain_user_requests_into_messages(task) - - if task.process is not None and not task.is_alive(): - if not task.messages or task.messages[-1].get("tag") != "END": - task.messages.append( - { - "tag": "END", - "timestamp": datetime.now(timezone.utc).isoformat(), - "content": { - "error_msg": "RD-Agent process has completed.", - "end_code": task.get_end_code(), - }, - } - ) - app.logger.warning(f"Process for {trace_id} has ended.") - - user_ip = request.remote_addr - - if reset: - task.pointers[user_ip] = 0 - - start_pointer = task.pointers[user_ip] - end_pointer = start_pointer + msg_num - if end_pointer > len(task.messages) or return_all: - end_pointer = len(task.messages) - - returned_msgs = task.messages[start_pointer:end_pointer] - task.pointers[user_ip] = end_pointer - if returned_msgs: - app.logger.info([msg["tag"] for msg in returned_msgs]) - return jsonify(returned_msgs), 200 - - -@app.route("/stdout", methods=["GET"]) -def download_stdout_file(): - trace_id = request.args.get("id", "") - stdout_path = _resolve_stdout_path(trace_id) - - if stdout_path is None: - return jsonify({"error": "Trace ID is required or invalid"}), 400 - if not stdout_path.exists() or not stdout_path.is_file(): - return jsonify({"error": "Stdout file not found"}), 404 - - return send_file( - stdout_path, - as_attachment=True, - download_name=stdout_path.name, - mimetype="text/plain", - ) - - -@app.route("/upload", methods=["POST"]) -def upload_file(): - # ่Žทๅ–่ฏทๆฑ‚ไฝ“ไธญ็š„ๅญ—ๆฎต - global rdagent_processes - scenario = request.form.get("scenario") - files = request.files.getlist("files") - competition = request.form.get("competition") - loop_n = request.form.get("loops") - all_duration = request.form.get("all_duration") - - # scenario = "Data Science Loop" - if scenario == "Data Science": - competition = competition[10:] # Eg. MLE-Bench:aerial-cactus-competition - trace_name = f"{competition}-{randomname.get_name()}" - else: - trace_name = randomname.get_name() - trace_files_path = log_folder_path / "uploads" / scenario / trace_name - - log_trace_path = (log_folder_path / scenario / trace_name).absolute() - stdout_path = log_folder_path / scenario / f"{trace_name}.log" - if not stdout_path.exists(): - stdout_path.parent.mkdir(parents=True, exist_ok=True) - - # save files - for file in files: - if file: - p = (log_folder_path / "uploads" / scenario / trace_name).resolve() - sanitized_filename = secure_filename(file.filename) # Sanitize filename - target_path = (p / sanitized_filename).resolve() # Normalize target path - # Ensure target_path is within the allowed base directory - if os.path.commonpath([str(target_path), str(p)]) == str(p) and target_path.is_file() == False: - if not p.exists(): - p.mkdir(parents=True, exist_ok=True) - file.save(target_path) - else: - return jsonify({"error": "Invalid file path"}), 400 - - target_name = None - kwargs = {} - loop_n_val = int(loop_n) if loop_n else None - all_duration_val = f"{all_duration}h" if all_duration else None - - if scenario == "Finance Data Building": - target_name = "fin_factor" - kwargs = { - "loop_n": loop_n_val, - "all_duration": all_duration_val, - "base_features_path": str(trace_files_path), - } - if scenario == "Finance Model Implementation": - target_name = "fin_model" - kwargs = { - "loop_n": loop_n_val, - "all_duration": all_duration_val, - "base_features_path": str(trace_files_path), - } - if scenario == "Finance Whole Pipeline": - target_name = "fin_quant" - kwargs = { - "loop_n": loop_n_val, - "all_duration": all_duration_val, - "base_features_path": str(trace_files_path), - } - if scenario == "Finance Data Building (Reports)": - target_name = "fin_factor_report" - kwargs = {"report_folder": str(trace_files_path), "all_duration": all_duration_val} - if scenario == "General Model Implementation": - if len(files) == 0: # files is one link - rfp = request.form.get("files")[0] - else: # one file is uploaded - rfp = str(trace_files_path / files[0].filename) - target_name = "general_model" - kwargs = {"report_file_path": rfp} - if scenario == "Data Science": - target_name = "data_science" - kwargs = {"competition": competition, "loop_n": loop_n_val, "timeout": all_duration_val} - - if target_name is None: - return jsonify({"error": "Unknown scenario"}), 400 - - app.logger.info(f"Started process for {log_trace_path} with target: {target_name}, kwargs: {kwargs}") - task = RDAgentTask( - target_name=target_name, - kwargs=kwargs, - stdout_path=str(stdout_path), - log_trace_path=str(log_trace_path), - scenario=scenario, - trace_name=trace_name, - ui_server_port=app.config["UI_SERVER_PORT"], - ) - task.start() - app.logger.warning(f"Task {log_trace_path} started.") - rdagent_processes[str(log_trace_path)] = task - return ( - jsonify( - { - "id": f"{scenario}/{trace_name}", - } - ), - 200, - ) - - -@app.route("/receive", methods=["POST"]) -def receive_msgs(): - try: - data = request.get_json() - if not data: - return jsonify({"error": "No JSON data received"}), 400 - except Exception as e: - return jsonify({"error": "Internal Server Error"}), 500 - - if isinstance(data, list): - for d in data: - task = _get_or_create_task(d["id"]) - task.messages.append(d["msg"]) - else: - task = _get_or_create_task(data["id"]) - task.messages.append(data["msg"]) - - return jsonify({"status": "success"}), 200 - - -@app.route("/user_interaction/submit", methods=["POST"]) -def submit_user_interaction_response(): - """Frontend submits a user response; server forwards it to the rdagent subprocess via IPC queue.""" - data = request.get_json(silent=True) or {} - trace_id = data.get("id") - payload = data.get("payload") - - if not trace_id: - return jsonify({"error": "Trace ID is required"}), 400 - if payload is None: - return jsonify({"error": "Missing 'payload'"}), 400 - - trace_id = str(log_folder_path / trace_id) - task = _get_or_create_task(trace_id) - - try: - task.user_response_q.put(payload, block=False) - except Exception as e: - return jsonify({"error": f"Failed to enqueue user response: {e}"}), 500 - - return jsonify({"status": "success"}), 200 - - -@app.route("/control", methods=["POST"]) -def control_process(): - global rdagent_processes - data = request.get_json() - app.logger.info(data) - if not data or "id" not in data or "action" not in data: - return jsonify({"error": "Missing 'id' or 'action' in request"}), 400 - - id = str(log_folder_path / data["id"]) - action = data["action"] - - if action != "stop": - return jsonify({"error": "Only 'stop' action is supported"}), 400 - - if id not in rdagent_processes or rdagent_processes[id] is None: - return jsonify({"error": "No running process for given id"}), 400 - - task = rdagent_processes[id] - - if task.process is None: - return jsonify({"error": "No running process for given id"}), 400 - - try: - if task.is_alive(): - task.stop() - - if not task.messages or task.messages[-1].get("tag") != "END": - task.messages.append( - { - "tag": "END", - "timestamp": datetime.now(timezone.utc).isoformat(), - "content": {"error_msg": "RD-Agent process was stopped by user.", "end_code": -1}, - } - ) - app.logger.warning(f"Process for {id} has been stopped.") - return jsonify({"status": "stopped"}), 200 - except Exception as e: - return jsonify({"error": f"Failed to {action} process, {e}"}), 500 - - -@app.route("/test", methods=["GET"]) -def test(): - # return 'Hello, World!' - msgs = {k: [i["tag"] for i in task.messages] for k, task in rdagent_processes.items()} - pointers = {k: dict(task.pointers) for k, task in rdagent_processes.items()} - return jsonify({"msgs": msgs, "pointers": pointers}), 200 - - -@app.route("/", methods=["GET"]) -def index(): - # return 'Hello, World!' - # return {k: [i["tag"] for i in v] for k, v in msgs_for_frontend.items()} - return send_from_directory(app.static_folder, "index.html") - - -@app.route("/", methods=["GET"]) -def server_static_files(fn): - return send_from_directory(app.static_folder, _normalize_static_request_path(fn)) - - -def main(port: int = 19899): - app.config["UI_SERVER_PORT"] = port - app.run(debug=False, host="0.0.0.0", port=port) - - -if __name__ == "__main__": - typer.run(main) diff --git a/rdagent/log/server/debug_app.py b/rdagent/log/server/debug_app.py deleted file mode 100644 index 50265744..00000000 --- a/rdagent/log/server/debug_app.py +++ /dev/null @@ -1,174 +0,0 @@ -import multiprocessing -import os -import random -import signal -import subprocess -import threading -import time -from collections import defaultdict -from datetime import datetime, timezone -from pathlib import Path - -import randomname -import typer -from flask import Flask, jsonify, request, send_from_directory -from flask_cors import CORS - -from rdagent.log.ui.conf import UI_SETTING - -app = Flask(__name__, static_folder=UI_SETTING.static_path) -CORS(app) - -rdagent_processes = defaultdict() -server_port = 19899 - - -@app.route("/favicon.ico") -def favicon(): - return send_from_directory(app.static_folder, "favicon.ico", mimetype="image/vnd.microsoft.icon") - - -msgs_for_frontend = defaultdict(list) -pointers = defaultdict(int) - - -@app.route("/trace", methods=["POST"]) -def update_trace(): - global pointers, msgs_for_frontend - data = request.get_json() - # app.logger.info(data) - trace_id = data.get("id") - return_all = data.get("all") - reset = data.get("reset") - msg_num = random.randint(1, 10) - - if reset: - pointers[trace_id] = 0 - - end_pointer = pointers[trace_id] + msg_num - if end_pointer > len(msgs_for_frontend[trace_id]) or return_all: - end_pointer = len(msgs_for_frontend[trace_id]) - - returned_msgs = msgs_for_frontend[trace_id][pointers[trace_id] : end_pointer] - - pointers[trace_id] = end_pointer - # if len(returned_msgs): - # app.logger.info(data) - # app.logger.info([i["tag"] for i in returned_msgs]) - # try: - # import json - # resp = json.dumps(returned_msgs, ensure_ascii=False) - # except Exception as e: - # app.logger.error(f"Error in jsonify: {e}") - # for msg in returned_msgs: - # try: - # rr = json.dumps(msg, ensure_ascii=False) - # except Exception as e: - # app.logger.error(f"Error in jsonify individual message: {e}") - # app.logger.error(msg) - - return jsonify(returned_msgs), 200 - - -@app.route("/upload", methods=["POST"]) -def upload_file(): - # ่Žทๅ–่ฏทๆฑ‚ไฝ“ไธญ็š„ๅญ—ๆฎต - global rdagent_processes, server_port, msgs_for_frontend - scenario = request.form.get("scenario") - files = request.files.getlist("files") - competition = request.form.get("competition") - loop_n = request.form.get("loops") - all_duration = request.form.get("all_duration") - - log_folder_path = Path("/home/bowen/workspace/new_traces").absolute() - - if scenario == "Data Science": - trace_path = log_folder_path / "o1-preview" / f"{competition[10:]}.1" - else: - trace_path = log_folder_path / scenario - id = f"{scenario}/{randomname.get_name()}" - - def read_trace(log_path: Path, t: float = 0.2, id: str = "") -> None: - from rdagent.log.storage import FileStorage - from rdagent.log.ui.storage import WebStorage - - fs = FileStorage(log_path) - ws = WebStorage(port=1, path=log_path) - msgs_for_frontend[id] = [] - for msg in fs.iter_msg(): - data = ws._obj_to_json(obj=msg.content, tag=msg.tag, id=id, timestamp=msg.timestamp.isoformat()) - if data: - if isinstance(data, list): - for d in data: - time.sleep(t) - msgs_for_frontend[id].append(d["msg"]) - else: - time.sleep(t) - msgs_for_frontend[id].append(data["msg"]) - msgs_for_frontend[id].append({"tag": "END", "timestamp": datetime.now(timezone.utc).isoformat(), "content": {}}) - - # ๅฏๅŠจๅŽๅฐ็บฟ็จ‹๏ผŒไธ้˜ปๅกž return - threading.Thread(target=read_trace, args=(trace_path, 0.5, id), daemon=True).start() - - return jsonify({"id": id}), 200 - - -@app.route("/receive", methods=["POST"]) -def receive_msgs(): - try: - data = request.get_json() - app.logger.info(data["msg"]["tag"]) - if not data: - return jsonify({"error": "No JSON data received"}), 400 - except Exception as e: - return jsonify({"error": "Internal Server Error"}), 500 - - if isinstance(data, list): - for d in data: - msgs_for_frontend[d["id"]].append(d["msg"]) - else: - msgs_for_frontend[data["id"]].append(data["msg"]) - - return jsonify({"status": "success"}), 200 - - -@app.route("/control", methods=["POST"]) -def control_process(): - global rdagent_processes - data = request.get_json() - app.logger.info(data) - if not data or "id" not in data or "action" not in data: - return jsonify({"error": "Missing 'id' or 'action' in request"}), 400 - - id = data["id"] - action = data["action"] - - return jsonify({"status": "success", "message": f"Received action '{action}' for process with id '{id}'"}) - - -@app.route("/test", methods=["GET"]) -def test(): - # return 'Hello, World!' - return {k: [i["tag"] for i in v] for k, v in msgs_for_frontend.items()} - - -@app.route("/", methods=["GET"]) -def index(): - # return 'Hello, World!' - # return {k: [i["tag"] for i in v] for k, v in msgs_for_frontend.items()} - return send_from_directory(app.static_folder, "index.html") - - -@app.route("/", methods=["GET"]) -def server_static_files(fn): - return send_from_directory(app.static_folder, fn) - - -def main(port: int = 19899): - global server_port - server_port = port - app.run(debug=True, host="0.0.0.0", port=port) - - -if __name__ == "__main__": - typer.run(main) diff --git a/rdagent/log/storage.py b/rdagent/log/storage.py deleted file mode 100644 index 1aca9df8..00000000 --- a/rdagent/log/storage.py +++ /dev/null @@ -1,116 +0,0 @@ -import json -import pickle -import re -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Generator, Literal - -from .base import Message, Storage -from .utils import gen_datetime - -LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - - -def _remove_empty_dir(path: Path) -> None: - """ - Recursively remove empty directories. - This function will remove the directory if it is empty after removing its subdirectories. - """ - if path.is_dir(): - sub_dirs = [sub for sub in path.iterdir() if sub.is_dir()] - for sub in sub_dirs: - _remove_empty_dir(sub) - - if not any(path.iterdir()): - path.rmdir() - - -class FileStorage(Storage): - """ - The info are logginged to the file systems - - TODO: describe the storage format - """ - - def __init__(self, path: str | Path) -> None: - self.path = Path(path) - - def log( - self, - obj: object, - tag: str = "", - timestamp: datetime | None = None, - save_type: Literal["json", "text", "pkl"] = "pkl", - **kwargs: Any, - ) -> str | Path: - # TODO: We can remove the timestamp after we implement PipeLog - timestamp = gen_datetime(timestamp) - - cur_p = self.path / tag.replace(".", "/") - cur_p.mkdir(parents=True, exist_ok=True) - - path = cur_p / f"{timestamp.strftime('%Y-%m-%d_%H-%M-%S-%f')}.log" - - if save_type == "json": - path = path.with_suffix(".json") - with path.open("w") as f: - try: - json.dump(obj, f) - except TypeError: - json.dump(json.loads(str(obj)), f) - return path - elif save_type == "pkl": - path = path.with_suffix(".pkl") - with path.open("wb") as f: - pickle.dump(obj, f) - return path - elif save_type == "text": - obj = str(obj) - with path.open("w") as f: - f.write(obj) - return path - - log_pattern = re.compile( - r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \| " - r"(?PDEBUG|INFO|WARNING|ERROR|CRITICAL) *\| " - r"(?P.+:.+:\d+) - " - ) - - def iter_msg(self, tag: str | None = None, pattern: str | None = None) -> Generator[Message, None, None]: - msg_l = [] - - if pattern: - pkl_files = pattern - elif tag: - pkl_files = f"**/{tag.replace('.','/')}/**/*.pkl" - else: - pkl_files = "**/*.pkl" - for file in self.path.glob(pkl_files): - if file.name == "debug_llm.pkl": - continue - pkl_log_tag = ".".join(file.relative_to(self.path).as_posix().replace("/", ".").split(".")[:-3]) - pid = file.parent.name - - with file.open("rb") as f: - content = pickle.load(f) - - timestamp = datetime.strptime(file.stem, "%Y-%m-%d_%H-%M-%S-%f").replace(tzinfo=timezone.utc) - - m = Message(tag=pkl_log_tag, level="INFO", timestamp=timestamp, caller="", pid_trace=pid, content=content) - - msg_l.append(m) - - msg_l.sort(key=lambda x: x.timestamp) - for m in msg_l: - yield m - - def truncate(self, time: datetime) -> None: - for file in self.path.glob("**/*.pkl"): - timestamp = datetime.strptime(file.stem, "%Y-%m-%d_%H-%M-%S-%f").replace(tzinfo=timezone.utc) - if timestamp > time.replace(tzinfo=timezone.utc): - file.unlink() - - _remove_empty_dir(self.path) - - def __str__(self) -> str: - return f"FileStorage({self.path})" diff --git a/rdagent/log/timer.py b/rdagent/log/timer.py deleted file mode 100644 index a130541f..00000000 --- a/rdagent/log/timer.py +++ /dev/null @@ -1,86 +0,0 @@ -import re -from datetime import datetime, timedelta - -from rdagent.core.utils import SingletonBaseClass -from rdagent.log import rdagent_logger as logger - - -class RDAgentTimer: - def __init__(self) -> None: - self.started: bool = False - self.target_time: datetime | None = None - self.all_duration: timedelta | None = None - self._remain_time_duration: timedelta | None = None - - def reset(self, all_duration: str | timedelta) -> None: - if isinstance(all_duration, str): - pattern = re.compile(r"^\s*(\d*\.?\d+)\s*([smhd]?)\s*$") - - match = pattern.match(all_duration) - if not match: - return None - value = float(match.group(1)) - unit = match.group(2) - if unit == "s": - self.all_duration = timedelta(seconds=value) - elif unit == "m": - self.all_duration = timedelta(minutes=value) - elif unit == "h": - self.all_duration = timedelta(hours=value) - elif unit == "d": - self.all_duration = timedelta(days=value) - else: - self.all_duration = timedelta(seconds=value) - elif isinstance(all_duration, timedelta): - self.all_duration = all_duration - self.target_time = datetime.now() + self.all_duration - logger.info(f"Timer set to {self.all_duration} seconds and counting down.") - self.started = True - return None - - def restart_by_remain_time(self) -> None: - if self._remain_time_duration is not None: - self.target_time = datetime.now() + self._remain_time_duration - self.started = True - logger.info(f"Timer restarted with remaining time: {self._remain_time_duration}") - else: - logger.warning("No remaining time to restart the timer.") - return None - - def add_duration(self, duration: timedelta) -> None: - if self.started and self.target_time is not None: - logger.info(f"Adding {duration} to the timer. Currently {self.remain_time()} remains.") - self.target_time = self.target_time + duration - self.update_remain_time() - - def is_timeout(self) -> bool: - if self.started and self.target_time is not None: - self.update_remain_time() - if datetime.now() > self.target_time: - return True - return False - - def update_remain_time(self) -> None: - if self.started and self.target_time is not None: - self._remain_time_duration = self.target_time - datetime.now() - return None - - def remain_time(self) -> timedelta | None: - if self.started: - self.update_remain_time() - return self._remain_time_duration - return None - - -class RDAgentTimerWrapper(SingletonBaseClass): - def __init__(self) -> None: - self.timer: RDAgentTimer = RDAgentTimer() - self.api_fail_count: int = 0 - self.latest_api_fail_time: datetime | None = None - - def replace_timer(self, timer: RDAgentTimer) -> None: - self.timer = timer - logger.info("Timer replaced successfully.") - - -RD_Agent_TIMER_wrapper: RDAgentTimerWrapper = RDAgentTimerWrapper() diff --git a/rdagent/log/ui/__init__.py b/rdagent/log/ui/__init__.py deleted file mode 100644 index 6040d81b..00000000 --- a/rdagent/log/ui/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -UI is a kind of view for user. - -We are not sure how generality of the UI, we can't make decision among following options: -- in general folder like rdagent/log/ui -- It is for specific scenario rdagent/scenarios/ -""" diff --git a/rdagent/log/ui/aide.py b/rdagent/log/ui/aide.py deleted file mode 100644 index ded20274..00000000 --- a/rdagent/log/ui/aide.py +++ /dev/null @@ -1,53 +0,0 @@ -# %% -import json -from pathlib import Path - -import streamlit as st - -from rdagent.log.ui.conf import UI_SETTING -from rdagent.utils.repo.diff import generate_diff_from_dict - -aide_path = UI_SETTING.aide_path -if not Path(aide_path).exists(): - st.error(f"Path {aide_path} does not exist, set it by `UI_AIDE_PATH`") - st.stop() - -jps = [str(i) for i in Path(aide_path).rglob("**/filtered_journal.json")] -jps = sorted(jps) -# st.write(jps) -left, right = st.columns([1, 4]) -with left: - default = 0 - ppp = f"{aide_path}/{st.query_params.get('jnp')}/logs/filtered_journal.json" - if ppp in jps: - default = jps.index(ppp) - - jnp = st.radio("Select Journal", options=jps, index=default, format_func=lambda x: str(x).split("/")[-3]) - jnp = Path(jnp) - - -with jnp.open("r") as f: - d = json.load(f) -# with jnp_.open("r") as f: -# d1 = json.load(f) - -nm = {nd["id"]: nd for nd in d["nodes"]} -# %% -with right: - st.header("AIDE trace", divider="rainbow") - st.subheader(jnp) - for c, p in d["node2parent"].items(): - f = nm[p] - t = nm[c] - df_lines = generate_diff_from_dict({"aide.py": f["code"]}, {"aide.py": t["code"]}) - - with st.expander(f"Node {p} -> {c}"): - st.markdown(f"## Parent ({f['metric']['value']}) Analysis") - st.code(f["analysis"], wrap_lines=True) - st.markdown(f"## Child ({t['metric']['value']}) Plan") - st.code(t["plan"], wrap_lines=True) - st.markdown("## Diff") - st.code("".join(df_lines), language="diff", wrap_lines=True) - # print("".join(df_lines)) - -# %% diff --git a/rdagent/log/ui/app.py b/rdagent/log/ui/app.py deleted file mode 100644 index 881b32fb..00000000 --- a/rdagent/log/ui/app.py +++ /dev/null @@ -1,1133 +0,0 @@ -import argparse -import re -import textwrap -from collections import defaultdict -from datetime import datetime, timezone -from importlib.resources import files as rfiles -from pathlib import Path -from typing import Callable, Type - -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go -import streamlit as st -from plotly.subplots import make_subplots -from streamlit import session_state as state -from streamlit_theme import st_theme - -from rdagent.components.coder.factor_coder.evaluators import FactorSingleFeedback -from rdagent.components.coder.factor_coder.factor import FactorFBWorkspace, FactorTask -from rdagent.components.coder.model_coder.evaluators import ModelSingleFeedback -from rdagent.components.coder.model_coder.model import ModelFBWorkspace, ModelTask -from rdagent.core.proposal import Hypothesis, HypothesisFeedback -from rdagent.core.scenario import Scenario -from rdagent.log.base import Message -from rdagent.log.storage import FileStorage -from rdagent.log.ui.qlib_report_figure import report_figure -from rdagent.scenarios.general_model.scenario import GeneralModelScenario -from rdagent.scenarios.kaggle.experiment.scenario import KGScenario -from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorScenario -from rdagent.scenarios.qlib.experiment.factor_from_report_experiment import ( - QlibFactorFromReportScenario, -) -from rdagent.scenarios.qlib.experiment.model_experiment import ( - QlibModelExperiment, - QlibModelScenario, -) -from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario - -st.set_page_config(layout="wide", page_title="RD-Agent", page_icon="๐ŸŽ“", initial_sidebar_state="expanded") - - -# ่Žทๅ–log_pathๅ‚ๆ•ฐ -parser = argparse.ArgumentParser(description="RD-Agent Streamlit App") -parser.add_argument("--log_dir", type=str, help="Path to the log directory") -parser.add_argument("--debug", action="store_true", help="Enable debug mode") -args = parser.parse_args() -if args.log_dir: - main_log_path = Path(args.log_dir) - if not main_log_path.exists(): - st.error(f"Log dir `{main_log_path}` does not exist!") - st.stop() -else: - main_log_path = None - - -QLIB_SELECTED_METRICS = [ - "IC", - "1day.excess_return_with_cost.annualized_return", - "1day.excess_return_with_cost.information_ratio", - "1day.excess_return_with_cost.max_drawdown", -] - -SIMILAR_SCENARIOS = ( - QlibModelScenario, - QlibFactorScenario, - QlibFactorFromReportScenario, - QlibQuantScenario, - KGScenario, -) - - -def filter_log_folders(main_log_path): - """ - Filter and return the log folders relative to the main log path. - """ - folders = [folder.relative_to(main_log_path) for folder in main_log_path.iterdir() if folder.is_dir()] - folders = sorted(folders, key=lambda x: x.name) - return folders - - -if "log_path" not in state: - if main_log_path: - state.log_path = filter_log_folders(main_log_path)[0] - else: - state.log_path = None - st.toast(":red[**Please Set Log Path!**]", icon="โš ๏ธ") - -if "scenario" not in state: - state.scenario = None - -if "fs" not in state: - state.fs = None - -if "msgs" not in state: - state.msgs = defaultdict(lambda: defaultdict(list)) - -if "last_msg" not in state: - state.last_msg = None - -if "current_tags" not in state: - state.current_tags = [] - -if "lround" not in state: - state.lround = 0 # RD Loop Round - -if "erounds" not in state: - state.erounds = defaultdict(int) # Evolving Rounds in each RD Loop - -if "e_decisions" not in state: - state.e_decisions = defaultdict(lambda: defaultdict(tuple)) - -# Summary Info -if "hypotheses" not in state: - # Hypotheses in each RD Loop - state.hypotheses = defaultdict(None) - -if "h_decisions" not in state: - state.h_decisions = defaultdict(bool) - -if "metric_series" not in state: - state.metric_series = [] - -if "all_metric_series" not in state: - state.all_metric_series = [] - -# Factor Task Baseline -if "alpha_baseline_metrics" not in state: - state.alpha_baseline_metrics = None - - -def should_display(msg: Message): - for t in state.excluded_tags + ["debug_tpl", "debug_llm"]: - if t in msg.tag.split("."): - return False - - if type(msg.content).__name__ in state.excluded_types: - return False - - return True - - -def get_msgs_until(end_func: Callable[[Message], bool] = lambda _: True): - if state.fs: - while True: - try: - msg = next(state.fs) - if should_display(msg): - tags = msg.tag.split(".") - if "hypothesis generation" in msg.tag: - state.lround += 1 - - # new scenario gen this tags, old version UI not have these tags. - msg.tag = re.sub(r"\.evo_loop_\d+", "", msg.tag) - msg.tag = re.sub(r"Loop_\d+\.[^.]+", "", msg.tag) - msg.tag = re.sub(r"\.\.", ".", msg.tag) - - # remove old redundant tags - msg.tag = re.sub(r"init\.", "", msg.tag) - msg.tag = re.sub(r"r\.", "", msg.tag) - msg.tag = re.sub(r"d\.", "", msg.tag) - msg.tag = re.sub(r"ef\.", "", msg.tag) - - msg.tag = msg.tag.strip(".") - - if "evolving code" not in state.current_tags and "evolving code" in tags: - state.erounds[state.lround] += 1 - - state.current_tags = tags - state.last_msg = msg - - # Update Summary Info - if "runner result" in tags: - # factor baseline exp metrics - if ( - isinstance(state.scenario, (QlibFactorScenario, QlibQuantScenario)) - and state.alpha_baseline_metrics is None - ): - try: - sms = msg.content.based_experiments[0].result - except AttributeError: - sms = msg.content.based_experiments[0].__dict__["result"] - sms = sms.loc[QLIB_SELECTED_METRICS] - sms.name = "Alpha Base" - state.alpha_baseline_metrics = sms - - if state.lround == 1 and len(msg.content.based_experiments) > 0: - try: - sms = msg.content.based_experiments[-1].result - except AttributeError: - sms = msg.content.based_experiments[-1].__dict__["result"] - if sms is not None: - if isinstance( - state.scenario, - ( - QlibModelScenario, - QlibFactorFromReportScenario, - QlibFactorScenario, - QlibQuantScenario, - ), - ): - sms_all = sms - sms = sms.loc[QLIB_SELECTED_METRICS] - sms.name = f"Baseline" - state.metric_series.append(sms) - state.all_metric_series.append(sms_all) - - # common metrics - try: - sms = msg.content.result - except AttributeError: - sms = msg.content.__dict__["result"] - if isinstance( - state.scenario, - ( - QlibModelScenario, - QlibFactorFromReportScenario, - QlibFactorScenario, - QlibQuantScenario, - ), - ): - sms_all = sms - sms = sms.loc[QLIB_SELECTED_METRICS] - - sms.name = f"Round {state.lround}" - sms_all.name = f"Round {state.lround}" - state.metric_series.append(sms) - state.all_metric_series.append(sms_all) - elif "hypothesis generation" in tags: - state.hypotheses[state.lround] = msg.content - elif "evolving code" in tags: - msg.content = [i for i in msg.content if i] - elif "evolving feedback" in tags: - total_len = len(msg.content) - none_num = total_len - len(msg.content) - right_num = 0 - for wsf in msg.content: - if wsf.final_decision: - right_num += 1 - wrong_num = len(msg.content) - right_num - state.e_decisions[state.lround][state.erounds[state.lround]] = ( - right_num, - wrong_num, - none_num, - ) - elif "feedback" in tags and isinstance(msg.content, HypothesisFeedback): - state.h_decisions[state.lround] = msg.content.decision - - state.msgs[state.lround][msg.tag].append(msg) - - # Stop Getting Logs - if end_func(msg): - break - except StopIteration: - st.toast(":red[**No More Logs to Show!**]", icon="๐Ÿ›‘") - break - - -def refresh(same_trace: bool = False): - if state.log_path is None: - st.toast(":red[**Please Set Log Path!**]", icon="โš ๏ธ") - return - - if main_log_path: - state.fs = FileStorage(main_log_path / state.log_path).iter_msg() - else: - state.fs = FileStorage(state.log_path).iter_msg() - - # detect scenario - if not same_trace: - get_msgs_until(lambda m: isinstance(m.content, Scenario)) - if state.last_msg is None or not isinstance(state.last_msg.content, Scenario): - st.write(state.msgs) - st.toast(":red[**No Scenario Info detected**]", icon="โ—") - state.scenario = None - else: - state.scenario = state.last_msg.content - st.toast(f":green[**Scenario Info detected**] *{type(state.scenario).__name__}*", icon="โœ…") - - state.msgs = defaultdict(lambda: defaultdict(list)) - state.lround = 0 - state.erounds = defaultdict(int) - state.e_decisions = defaultdict(lambda: defaultdict(tuple)) - state.hypotheses = defaultdict(None) - state.h_decisions = defaultdict(bool) - state.metric_series = [] - state.all_metric_series = [] - state.last_msg = None - state.current_tags = [] - state.alpha_baseline_metrics = None - - -def evolving_feedback_window(wsf: FactorSingleFeedback | ModelSingleFeedback): - if isinstance(wsf, FactorSingleFeedback): - ffc, efc, cfc, vfc = st.tabs( - ["**Final Feedback๐Ÿ**", "Execution Feedback๐Ÿ–ฅ๏ธ", "Code Feedback๐Ÿ“„", "Value Feedback๐Ÿ”ข"] - ) - with ffc: - st.markdown(wsf.final_feedback) - with efc: - st.code(wsf.execution_feedback, language="log") - with cfc: - st.markdown(wsf.code_feedback) - with vfc: - st.markdown(wsf.value_feedback) - elif isinstance(wsf, ModelSingleFeedback): - ffc, efc, cfc, msfc, vfc = st.tabs( - [ - "**Final Feedback๐Ÿ**", - "Execution Feedback๐Ÿ–ฅ๏ธ", - "Code Feedback๐Ÿ“„", - "Model Shape Feedback๐Ÿ“", - "Value Feedback๐Ÿ”ข", - ] - ) - with ffc: - st.markdown(wsf.final_feedback) - with efc: - st.code(wsf.execution_feedback, language="log") - with cfc: - st.markdown(wsf.code_feedback) - with msfc: - st.markdown(wsf.shape_feedback) - with vfc: - st.markdown(wsf.value_feedback) - - -def display_hypotheses(hypotheses: dict[int, Hypothesis], decisions: dict[int, bool], success_only: bool = False): - name_dict = { - "hypothesis": "RD-Agent proposes the hypothesisโฌ‡๏ธ", - "concise_justification": "because the reasonโฌ‡๏ธ", - "concise_observation": "based on the observationโฌ‡๏ธ", - "concise_knowledge": "Knowledgeโฌ‡๏ธ gained after practice", - } - if success_only: - shd = {k: v.__dict__ for k, v in hypotheses.items() if decisions[k]} - else: - shd = {k: v.__dict__ for k, v in hypotheses.items()} - df = pd.DataFrame(shd).T - - if "concise_observation" in df.columns and "concise_justification" in df.columns: - df["concise_observation"], df["concise_justification"] = df["concise_justification"], df["concise_observation"] - df.rename( - columns={"concise_observation": "concise_justification", "concise_justification": "concise_observation"}, - inplace=True, - ) - if "reason" in df.columns: - df.drop(["reason"], axis=1, inplace=True) - if "concise_reason" in df.columns: - df.drop(["concise_reason"], axis=1, inplace=True) - - df.columns = df.columns.map(lambda x: name_dict.get(x, x)) - for col in list(df.columns): - if all([value is None for value in df[col]]): - df.drop([col], axis=1, inplace=True) - - def style_rows(row): - if decisions[row.name]: - return ["color: green;"] * len(row) - return [""] * len(row) - - def style_columns(col): - if col.name != name_dict.get("hypothesis", "hypothesis"): - return ["font-style: italic;"] * len(col) - return ["font-weight: bold;"] * len(col) - - # st.dataframe(df.style.apply(style_rows, axis=1).apply(style_columns, axis=0)) - st.markdown(df.style.apply(style_rows, axis=1).apply(style_columns, axis=0).to_html(), unsafe_allow_html=True) - - -def metrics_window(df: pd.DataFrame, R: int, C: int, *, height: int = 300, colors: list[str] = None): - fig = make_subplots(rows=R, cols=C, subplot_titles=df.columns) - - def hypothesis_hover_text(h: Hypothesis, d: bool = False): - color = "green" if d else "black" - text = h.hypothesis - lines = textwrap.wrap(text, width=60) - return f"{'
'.join(lines)}
" - - hover_texts = [ - hypothesis_hover_text(state.hypotheses[int(i[6:])], state.h_decisions[int(i[6:])]) - for i in df.index - if i != "Alpha Base" and i != "Baseline" - ] - if state.alpha_baseline_metrics is not None: - hover_texts = ["Baseline"] + hover_texts - for ci, col in enumerate(df.columns): - row = ci // C + 1 - col_num = ci % C + 1 - fig.add_trace( - go.Scatter( - x=df.index, - y=df[col], - name=col, - mode="lines+markers", - connectgaps=True, - marker=dict(size=10, color=colors[ci]) if colors else dict(size=10), - hovertext=hover_texts, - hovertemplate="%{hovertext}

%{x} Value: %{y}", - ), - row=row, - col=col_num, - ) - fig.update_layout(showlegend=False, height=height) - - if state.alpha_baseline_metrics is not None: - for i in range(1, R + 1): # ่กŒ - for j in range(1, C + 1): # ๅˆ— - fig.update_xaxes( - tickvals=[df.index[0]] + list(df.index[1:]), - ticktext=[f'{df.index[0]}'] + list(df.index[1:]), - row=i, - col=j, - ) - st.plotly_chart(fig) - - from io import BytesIO - - buffer = BytesIO() - df.to_csv(buffer) - buffer.seek(0) - st.download_button(label="download the metrics (csv)", data=buffer, file_name="metrics.csv", mime="text/csv") - - -def summary_window(): - if isinstance(state.scenario, SIMILAR_SCENARIOS): - st.header("Summary๐Ÿ“Š", divider="rainbow", anchor="_summary") - if state.lround == 0: - return - with st.container(): - # TODO: not fixed height - with st.container(): - bc, cc = st.columns([2, 2], vertical_alignment="center") - with bc: - st.subheader("Metrics๐Ÿ“ˆ", anchor="_metrics") - with cc: - show_true_only = st.toggle("successful hypotheses", value=False) - - # hypotheses_c, chart_c = st.columns([2, 3]) - chart_c = st.container() - hypotheses_c = st.container() - - with hypotheses_c: - st.subheader("Hypotheses๐Ÿ…", anchor="_hypotheses") - display_hypotheses(state.hypotheses, state.h_decisions, show_true_only) - - with chart_c: - if isinstance(state.scenario, QlibFactorScenario) and state.alpha_baseline_metrics is not None: - df = pd.DataFrame([state.alpha_baseline_metrics] + state.metric_series[1:]) - elif isinstance(state.scenario, QlibQuantScenario) and state.alpha_baseline_metrics is not None: - df = pd.DataFrame([state.alpha_baseline_metrics] + state.metric_series[1:]) - else: - df = pd.DataFrame(state.metric_series) - if show_true_only and len(state.hypotheses) >= len(state.metric_series): - if state.alpha_baseline_metrics is not None: - selected = ["Alpha Base"] + [ - i for i in df.index if i == "Baseline" or state.h_decisions[int(i[6:])] - ] - else: - selected = [i for i in df.index if i == "Baseline" or state.h_decisions[int(i[6:])]] - df = df.loc[selected] - if df.shape[0] == 1: - st.table(df.iloc[0]) - elif df.shape[0] > 1: - if df.shape[1] == 1: - fig = px.line(df, x=df.index, y=df.columns, markers=True) - fig.update_layout(xaxis_title="Loop Round", yaxis_title=None) - st.plotly_chart(fig) - else: - metrics_window(df, 1, 4, height=300, colors=["red", "blue", "orange", "green"]) - - elif isinstance(state.scenario, GeneralModelScenario): - with st.container(border=True): - st.subheader("Summary๐Ÿ“Š", divider="rainbow", anchor="_summary") - if len(state.msgs[state.lround]["evolving code"]) > 0: - # pass - ws: list[FactorFBWorkspace | ModelFBWorkspace] = state.msgs[state.lround]["evolving code"][-1].content - # All Tasks - - tab_names = [ - w.target_task.factor_name if isinstance(w.target_task, FactorTask) else w.target_task.name - for w in ws - ] - for j in range(len(ws)): - if state.msgs[state.lround]["evolving feedback"][-1].content[j].final_decision: - tab_names[j] += "โœ”๏ธ" - else: - tab_names[j] += "โŒ" - - wtabs = st.tabs(tab_names) - for j, w in enumerate(ws): - with wtabs[j]: - # Evolving Code - for k, v in w.file_dict.items(): - with st.expander(f":green[`{k}`]", expanded=False): - st.code(v, language="python") - - # Evolving Feedback - evolving_feedback_window(state.msgs[state.lround]["evolving feedback"][-1].content[j]) - - -def tabs_hint(): - st.markdown( - "

You can navigate through the tabs using โฌ…๏ธ โžก๏ธ or by holding Shift and scrolling with the mouse wheel๐Ÿ–ฑ๏ธ.

", - unsafe_allow_html=True, - ) - - -def tasks_window(tasks: list[FactorTask | ModelTask]): - if isinstance(tasks[0], FactorTask): - st.markdown("**Factor Tasks๐Ÿšฉ**") - tnames = [f.factor_name for f in tasks] - if sum(len(tn) for tn in tnames) > 100: - tabs_hint() - tabs = st.tabs(tnames) - for i, ft in enumerate(tasks): - with tabs[i]: - # st.markdown(f"**Factor Name**: {ft.factor_name}") - st.markdown(f"**Description**: {ft.factor_description}") - st.latex("Formulation") - st.latex(ft.factor_formulation) - - mks = "| Variable | Description |\n| --- | --- |\n" - if isinstance(ft.variables, dict): - for v, d in ft.variables.items(): - mks += f"| ${v}$ | {d} |\n" - st.markdown(mks) - - elif isinstance(tasks[0], ModelTask): - st.markdown("**Model Tasks๐Ÿšฉ**") - tnames = [m.name for m in tasks] - if sum(len(tn) for tn in tnames) > 100: - tabs_hint() - tabs = st.tabs(tnames) - for i, mt in enumerate(tasks): - with tabs[i]: - # st.markdown(f"**Model Name**: {mt.name}") - st.markdown(f"**Model Type**: {mt.model_type}") - st.markdown(f"**Description**: {mt.description}") - st.latex("Formulation") - st.latex(mt.formulation) - - mks = "| Variable | Description |\n| --- | --- |\n" - if mt.variables: - for v, d in mt.variables.items(): - mks += f"| ${v}$ | {d} |\n" - st.markdown(mks) - st.markdown(f"**Train Para**: {mt.training_hyperparameters}") - - -def research_window(): - with st.container(border=True): - title = "Research๐Ÿ”" if isinstance(state.scenario, SIMILAR_SCENARIOS) else "Research๐Ÿ” (reader)" - st.subheader(title, divider="blue", anchor="_research") - if isinstance(state.scenario, SIMILAR_SCENARIOS): - # pdf image - if pim := state.msgs[round]["load_pdf_screenshot"]: - for i in range(min(2, len(pim))): - st.image(pim[i].content, use_container_width=True) - - # Hypothesis - if hg := state.msgs[round]["hypothesis generation"]: - st.markdown("**Hypothesis๐Ÿ’ก**") # ๐Ÿง  - h: Hypothesis = hg[0].content - st.markdown(f""" -- **Hypothesis**: {h.hypothesis} -- **Reason**: {h.reason}""") - - if eg := state.msgs[round]["experiment generation"]: - tasks_window(eg[0].content) - - elif isinstance(state.scenario, GeneralModelScenario): - # pdf image - c1, c2 = st.columns([2, 3]) - with c1: - if pim := state.msgs[0]["pdf_image"]: - for i in range(len(pim)): - st.image(pim[i].content, use_container_width=True) - - # loaded model exp - with c2: - if mem := state.msgs[0]["load_experiment"]: - me: QlibModelExperiment = mem[0].content - tasks_window(me.sub_tasks) - - -def feedback_window(): - # st.write(round) - # # Check if metric series exists and has the matching round - # if state.all_metric_series: - # for metric in state.all_metric_series: - # if metric.name == f"Round {round}": - # # Select specific metrics with cost - # selected_metrics_with_cost = { - # 'IC': float(f"{metric['IC']:.4f}"), - # 'ICIR': float(f"{metric['ICIR']:.4f}"), - # 'Rank IC': float(f"{metric['Rank IC']:.4f}"), - # 'Rank ICIR': float(f"{metric['Rank ICIR']:.4f}"), - # 'ARR': float(f"{metric['1day.excess_return_with_cost.annualized_return']:.4f}"), - # 'IR': float(f"{metric['1day.excess_return_with_cost.information_ratio']:.4f}"), - # 'MDD': float(f"{metric['1day.excess_return_with_cost.max_drawdown']:.4f}"), - # 'Sharpe': float(f"{metric['1day.excess_return_with_cost.annualized_return'] / abs(metric['1day.excess_return_with_cost.max_drawdown']):.4f}") - # } - # st.write("With Cost Metrics:") - # st.write(pd.Series(selected_metrics_with_cost)) - - # # Select specific metrics without cost - # selected_metrics_without_cost = { - # 'IC': float(f"{metric['IC']:.4f}"), - # 'ICIR': float(f"{metric['ICIR']:.4f}"), - # 'Rank IC': float(f"{metric['Rank IC']:.4f}"), - # 'Rank ICIR': float(f"{metric['Rank ICIR']:.4f}"), - # 'ARR': float(f"{metric['1day.excess_return_without_cost.annualized_return']:.4f}"), - # 'IR': float(f"{metric['1day.excess_return_without_cost.information_ratio']:.4f}"), - # 'MDD': float(f"{metric['1day.excess_return_without_cost.max_drawdown']:.4f}"), - # 'Sharpe': float(f"{metric['1day.excess_return_without_cost.annualized_return'] / abs(metric['1day.excess_return_without_cost.max_drawdown']):.4f}") - # } - # st.write("Without Cost Metrics:") - # st.write(pd.Series(selected_metrics_without_cost)) - # break - if isinstance(state.scenario, SIMILAR_SCENARIOS): - with st.container(border=True): - st.subheader("Feedback๐Ÿ“", divider="orange", anchor="_feedback") - - if state.lround > 0 and isinstance( - state.scenario, - (QlibModelScenario, QlibFactorScenario, QlibFactorFromReportScenario, QlibQuantScenario, KGScenario), - ): - if fbr := state.msgs[round]["runner result"]: - try: - st.write("workspace") - st.write(fbr[0].content.experiment_workspace.workspace_path) - st.write(fbr[0].content.stdout) - except Exception as e: - st.error(f"Error displaying workspace path: {str(e)}") - with st.expander("**Configโš™๏ธ**", expanded=True): - st.markdown(state.scenario.experiment_setting, unsafe_allow_html=True) - - if fb := state.msgs[round]["feedback"]: - if fbr := state.msgs[round]["Quantitative Backtesting Chart"]: - st.markdown("**Returns๐Ÿ“ˆ**") - fig = report_figure(fbr[0].content) - st.plotly_chart(fig) - st.markdown("**Hypothesis Feedback๐Ÿ”**") - h: HypothesisFeedback = fb[0].content - st.markdown(f""" -- **Observations**: {h.observations} -- **Hypothesis Evaluation**: {h.hypothesis_evaluation} -- **New Hypothesis**: {h.new_hypothesis} -- **Decision**: {h.decision} -- **Reason**: {h.reason}""") - - if isinstance(state.scenario, KGScenario): - if fbe := state.msgs[round]["runner result"]: - submission_path = fbe[0].content.experiment_workspace.workspace_path / "submission.csv" - st.markdown( - f":green[**Exp Workspace**]: {str(fbe[0].content.experiment_workspace.workspace_path.absolute())}" - ) - try: - data = submission_path.read_bytes() - st.download_button( - label="**Download** submission.csv", - data=data, - file_name="submission.csv", - mime="text/csv", - ) - except Exception as e: - st.markdown(f":red[**Download Button Error**]: {e}") - - -def evolving_window(): - title = "Development๐Ÿ› ๏ธ" if isinstance(state.scenario, SIMILAR_SCENARIOS) else "Development๐Ÿ› ๏ธ (evolving coder)" - st.subheader(title, divider="green", anchor="_development") - - # Evolving Status - if state.erounds[round] > 0: - st.markdown("**โ˜‘๏ธ Evolving Status**") - es = state.e_decisions[round] - e_status_mks = "".join(f"| {ei} " for ei in range(1, state.erounds[round] + 1)) + "|\n" - e_status_mks += "|--" * state.erounds[round] + "|\n" - for ei, estatus in es.items(): - if not estatus: - estatus = (0, 0, 0) - e_status_mks += "| " + "๐Ÿ•™
" * estatus[2] + "โœ”๏ธ
" * estatus[0] + "โŒ
" * estatus[1] + " " - e_status_mks += "|\n" - st.markdown(e_status_mks, unsafe_allow_html=True) - - # Evolving Tabs - if state.erounds[round] > 0: - if state.erounds[round] > 1: - evolving_round = st.radio( - "**๐Ÿ”„๏ธEvolving Rounds**", - horizontal=True, - options=range(1, state.erounds[round] + 1), - index=state.erounds[round] - 1, - key="show_eround", - ) - else: - evolving_round = 1 - - ws: list[FactorFBWorkspace | ModelFBWorkspace] = state.msgs[round]["evolving code"][evolving_round - 1].content - # All Tasks - - tab_names = [ - w.target_task.factor_name if isinstance(w.target_task, FactorTask) else w.target_task.name for w in ws - ] - if len(state.msgs[round]["evolving feedback"]) >= evolving_round: - for j in range(len(ws)): - if state.msgs[round]["evolving feedback"][evolving_round - 1].content[j].final_decision: - tab_names[j] += "โœ”๏ธ" - else: - tab_names[j] += "โŒ" - if sum(len(tn) for tn in tab_names) > 100: - tabs_hint() - wtabs = st.tabs(tab_names) - for j, w in enumerate(ws): - with wtabs[j]: - # Evolving Code - st.markdown(f"**Workspace Path**: {w.workspace_path}") - for k, v in w.file_dict.items(): - with st.expander(f":green[`{k}`]", expanded=True): - st.code(v, language="python") - - # Evolving Feedback - if len(state.msgs[round]["evolving feedback"]) >= evolving_round: - evolving_feedback_window(state.msgs[round]["evolving feedback"][evolving_round - 1].content[j]) - - -toc = """ -## [Scenario Description๐Ÿ“–](#_scenario) -## [Summary๐Ÿ“Š](#_summary) -- [**Metrics๐Ÿ“ˆ**](#_metrics) -- [**Hypotheses๐Ÿ…**](#_hypotheses) -## [RD-Loopsโ™พ๏ธ](#_rdloops) -- [**Research๐Ÿ”**](#_research) -- [**Development๐Ÿ› ๏ธ**](#_development) -- [**Feedback๐Ÿ“**](#_feedback) -""" -if isinstance(state.scenario, GeneralModelScenario): - toc = """ -## [Scenario Description๐Ÿ“–](#_scenario) -### [Summary๐Ÿ“Š](#_summary) -### [Research๐Ÿ”](#_research) -### [Development๐Ÿ› ๏ธ](#_development) -""" -# Config Sidebar -with st.sidebar: - st.markdown("# RD-Agent๐Ÿค– [:grey[@GitHub]](https://github.com/microsoft/RD-Agent)") - st.subheader(":blue[Table of Content]", divider="blue") - st.markdown(toc) - st.subheader(":orange[Control Panel]", divider="red") - - with st.container(border=True): - if main_log_path: - lc1, lc2 = st.columns([1, 2], vertical_alignment="center") - with lc1: - st.markdown(":blue[**Log Path**]") - with lc2: - manually = st.toggle("Manual Input") - if manually: - st.text_input("log path", key="log_path", on_change=refresh, label_visibility="collapsed") - else: - folders = filter_log_folders(main_log_path) - st.selectbox(f"**Select from `{main_log_path}`**", folders, key="log_path", on_change=refresh) - else: - st.text_input(":blue[**log path**]", key="log_path", on_change=refresh) - - c1, c2 = st.columns([1, 1], vertical_alignment="center") - with c1: - if st.button(":green[**All Loops**]", use_container_width=True): - if not state.fs: - refresh() - get_msgs_until(lambda m: False) - if st.button("**Reset**", use_container_width=True): - refresh(same_trace=True) - with c2: - if st.button(":green[Next Loop]", use_container_width=True): - if not state.fs: - refresh() - get_msgs_until(lambda m: "feedback" in m.tag and "evolving feedback" not in m.tag) - - if st.button("Next Step", use_container_width=True): - if not state.fs: - refresh() - get_msgs_until(lambda m: "evolving feedback" in m.tag) - - with st.popover(":orange[**Configโš™๏ธ**]", use_container_width=True): - st.multiselect("excluded log tags", ["llm_messages"], ["llm_messages"], key="excluded_tags") - st.multiselect("excluded log types", ["str", "dict", "list"], ["str"], key="excluded_types") - - if args.debug: - debug = st.toggle("debug", value=False) - - if debug: - if st.button("Single Step Run", use_container_width=True): - get_msgs_until() - else: - debug = False - - -# Debug Info Window -if debug: - with st.expander(":red[**Debug Info**]", expanded=True): - dcol1, dcol2 = st.columns([1, 3]) - with dcol1: - st.markdown( - f"**log path**: {state.log_path}\n\n" - f"**excluded tags**: {state.excluded_tags}\n\n" - f"**excluded types**: {state.excluded_types}\n\n" - f":blue[**message id**]: {sum(sum(len(tmsgs) for tmsgs in rmsgs.values()) for rmsgs in state.msgs.values())}\n\n" - f":blue[**round**]: {state.lround}\n\n" - f":blue[**evolving round**]: {state.erounds[state.lround]}\n\n" - ) - with dcol2: - if state.last_msg: - st.write(state.last_msg) - if isinstance(state.last_msg.content, list): - st.write(state.last_msg.content[0]) - elif isinstance(state.last_msg.content, dict): - st.write(state.last_msg.content) - elif not isinstance(state.last_msg.content, str): - try: - st.write(state.last_msg.content.__dict__) - except: - st.write(type(state.last_msg.content)) - -if state.log_path and state.fs is None: - refresh() - -# Main Window -header_c1, header_c3 = st.columns([1, 6], vertical_alignment="center") -with st.container(): - with header_c1: - st.image("https://img-prod-cms-rt-microsoft-com.akamaized.net/cms/api/am/imageFileData/RE1Mu3b?ver=5c31") - with header_c3: - st.markdown( - """ -

- RD-Agent:
LLM-based autonomous evolving agents for industrial data-driven R&D -

- """, - unsafe_allow_html=True, - ) - -# Project Info -with st.container(): - image_c, scen_c = st.columns([3, 3], vertical_alignment="center") - with image_c: - img_path = rfiles("rdagent.log.ui").joinpath("flow.png") - st.image(str(img_path), use_container_width=True) - with scen_c: - st.header("Scenario Description๐Ÿ“–", divider="violet", anchor="_scenario") - if state.scenario is not None: - theme = st_theme() - if theme: - theme = theme.get("base", "light") - css = f""" - -""" - st.markdown(state.scenario.rich_style_description + css, unsafe_allow_html=True) - - -def analyze_task_completion(): - st.header("Task Completion Analysis", divider="orange") - - # Dictionary to store results for all loops - completion_stats = {} - - # Iterate through all loops - for loop_round in state.msgs.keys(): - if loop_round == 0: # Skip initialization round - continue - - max_evolving_round = state.erounds[loop_round] - if max_evolving_round == 0: - continue - - # Track tasks that pass in each evolving round - tasks_passed_by_round = {} - cumulative_passed = set() - - # For each evolving round in this loop - for e_round in range(1, max_evolving_round + 1): - if len(state.msgs[loop_round]["evolving feedback"]) >= e_round: - # Get feedback for this evolving round - feedback = state.msgs[loop_round]["evolving feedback"][e_round - 1].content - - # Count passed tasks and track their indices - passed_tasks = set() - for j, task_feedback in enumerate(feedback): - if task_feedback.final_decision: - passed_tasks.add(j) - cumulative_passed.add(j) - - # Store both individual round results and cumulative results - tasks_passed_by_round[e_round] = { - "count": len(passed_tasks), - "indices": passed_tasks, - "cumulative_count": len(cumulative_passed), - "cumulative_indices": cumulative_passed.copy(), - } - - completion_stats[loop_round] = { - "total_tasks": len(state.msgs[loop_round]["evolving feedback"][0].content), - "rounds": tasks_passed_by_round, - "max_round": max_evolving_round, - } - - # Display results - if completion_stats: - # Add an aggregate view at the top - st.subheader("๐Ÿ”„ Aggregate Completion Across All Loops") - - # Create summary data for comparison - summary_data = [] - total_tasks_across_loops = 0 - total_passed_r1 = 0 - total_passed_r3 = 0 - total_passed_r5 = 0 - total_passed_r10 = 0 - total_passed_final = 0 - - for loop_round, stats in completion_stats.items(): - total_tasks = stats["total_tasks"] - total_tasks_across_loops += total_tasks - - # Find data for specific rounds - r1_passed = stats["rounds"].get(1, {}).get("cumulative_count", 0) - total_passed_r1 += r1_passed - - # For round 3, use the closest round if exactly 3 doesn't exist - if 3 in stats["rounds"]: - r3_passed = stats["rounds"][3]["cumulative_count"] - elif stats["max_round"] >= 3: - max_r_below_3 = max([r for r in stats["rounds"].keys() if r <= 3]) - r3_passed = stats["rounds"][max_r_below_3]["cumulative_count"] - else: - r3_passed = stats["rounds"][stats["max_round"]]["cumulative_count"] if stats["rounds"] else 0 - total_passed_r3 += r3_passed - - # For round 5, use the closest round if exactly 5 doesn't exist - if 5 in stats["rounds"]: - r5_passed = stats["rounds"][5]["cumulative_count"] - elif stats["max_round"] >= 5: - max_r_below_5 = max([r for r in stats["rounds"].keys() if r <= 5]) - r5_passed = stats["rounds"][max_r_below_5]["cumulative_count"] - else: - r5_passed = stats["rounds"][stats["max_round"]]["cumulative_count"] if stats["rounds"] else 0 - total_passed_r5 += r5_passed - - # For round 10 - if 10 in stats["rounds"]: - r10_passed = stats["rounds"][10]["cumulative_count"] - else: - r10_passed = stats["rounds"][stats["max_round"]]["cumulative_count"] if stats["rounds"] else 0 - total_passed_r10 += r10_passed - - # Final round completion - final_passed = stats["rounds"][stats["max_round"]]["cumulative_count"] if stats["rounds"] else 0 - total_passed_final += final_passed - - # Add to summary table - summary_data.append( - { - "Loop": f"Loop {loop_round}", - "Total Tasks": total_tasks, - "Passed (Round 1)": ( - f"{r1_passed}/{total_tasks} ({r1_passed/total_tasks:.0%})" if total_tasks > 0 else "N/A" - ), - "Passed (Round 3)": ( - f"{r3_passed}/{total_tasks} ({r3_passed/total_tasks:.0%})" if total_tasks > 0 else "N/A" - ), - "Passed (Round 5)": ( - f"{r5_passed}/{total_tasks} ({r5_passed/total_tasks:.0%})" if total_tasks > 0 else "N/A" - ), - "Passed (Final)": ( - f"{final_passed}/{total_tasks} ({final_passed/total_tasks:.0%})" if total_tasks > 0 else "N/A" - ), - } - ) - - if total_tasks_across_loops > 0: - summary_data.append( - { - "Loop": "**TOTAL**", - "Total Tasks": total_tasks_across_loops, - "Passed (Round 1)": f"**{total_passed_r1}/{total_tasks_across_loops} ({total_passed_r1/total_tasks_across_loops:.0%})**", - "Passed (Round 3)": f"**{total_passed_r3}/{total_tasks_across_loops} ({total_passed_r3/total_tasks_across_loops:.0%})**", - "Passed (Round 5)": f"**{total_passed_r5}/{total_tasks_across_loops} ({total_passed_r5/total_tasks_across_loops:.0%})**", - "Passed (Final)": f"**{total_passed_final}/{total_tasks_across_loops} ({total_passed_final/total_tasks_across_loops:.0%})**", - } - ) - - st.table(pd.DataFrame(summary_data)) - - # Summary statistics - st.markdown("### ๐Ÿ“Š Overall Completion Progress:") - col1, col2, col3, col4 = st.columns(4) - with col1: - st.metric( - label="After Round 1", - value=f"{total_passed_r1/total_tasks_across_loops:.0%}", - help=f"{total_passed_r1}/{total_tasks_across_loops} tasks", - ) - with col2: - st.metric( - label="After Round 3", - value=f"{total_passed_r3/total_tasks_across_loops:.0%}", - delta=f"{(total_passed_r3-total_passed_r1)/total_tasks_across_loops:.0%}", - help=f"{total_passed_r3}/{total_tasks_across_loops} tasks", - ) - with col3: - st.metric( - label="After Round 5", - value=f"{total_passed_r5/total_tasks_across_loops:.0%}", - delta=f"{(total_passed_r5-total_passed_r3)/total_tasks_across_loops:.0%}", - help=f"{total_passed_r5}/{total_tasks_across_loops} tasks", - ) - with col4: - st.metric( - label="Final Completion", - value=f"{total_passed_final/total_tasks_across_loops:.0%}", - delta=f"{(total_passed_final-total_passed_r5)/total_tasks_across_loops:.0%}", - help=f"{total_passed_final}/{total_tasks_across_loops} tasks", - ) - - # Show detailed results by loop - st.markdown("---") - st.subheader("Detailed Results by Loop") - - for loop_round, stats in completion_stats.items(): - with st.expander(f"Loop {loop_round} Details"): - total_tasks = stats["total_tasks"] - - # Create a results table - data = [] - for e_round in range(1, min(11, stats["max_round"] + 1)): - if e_round in stats["rounds"]: - round_data = stats["rounds"][e_round] - data.append( - { - "Evolving Round": e_round, - "Tasks Passed": f"{round_data['count']}/{total_tasks} ({round_data['count']/total_tasks:.0%})", - "Cumulative Passed": f"{round_data['cumulative_count']}/{total_tasks} ({round_data['cumulative_count']/total_tasks:.0%})", - } - ) - else: - data.append({"Evolving Round": e_round, "Tasks Passed": "N/A", "Cumulative Passed": "N/A"}) - - df = pd.DataFrame(data) - st.table(df) - - st.markdown("### Summary:") - if 1 in stats["rounds"]: - st.markdown( - f"- After round 1: **{stats['rounds'][1]['cumulative_count']}/{total_tasks}** tasks passed ({stats['rounds'][1]['cumulative_count']/total_tasks:.0%})" - ) - - if 3 in stats["rounds"]: - st.markdown( - f"- After round 3: **{stats['rounds'][3]['cumulative_count']}/{total_tasks}** tasks passed ({stats['rounds'][3]['cumulative_count']/total_tasks:.0%})" - ) - elif stats["max_round"] >= 3: - max_round_below_3 = max([r for r in stats["rounds"].keys() if r <= 3]) - st.markdown( - f"- After round 3: **{stats['rounds'][max_round_below_3]['cumulative_count']}/{total_tasks}** tasks passed ({stats['rounds'][max_round_below_3]['cumulative_count']/total_tasks:.0%})" - ) - - if 5 in stats["rounds"]: - st.markdown( - f"- After round 5: **{stats['rounds'][5]['cumulative_count']}/{total_tasks}** tasks passed ({stats['rounds'][5]['cumulative_count']/total_tasks:.0%})" - ) - elif stats["max_round"] >= 5: - max_round_below_5 = max([r for r in stats["rounds"].keys() if r <= 5]) - st.markdown( - f"- After round 5: **{stats['rounds'][max_round_below_5]['cumulative_count']}/{total_tasks}** tasks passed ({stats['rounds'][max_round_below_5]['cumulative_count']/total_tasks:.0%})" - ) - - if 10 in stats["rounds"]: - st.markdown( - f"- After round 10: **{stats['rounds'][10]['cumulative_count']}/{total_tasks}** tasks passed ({stats['rounds'][10]['cumulative_count']/total_tasks:.0%})" - ) - elif stats["max_round"] >= 1: - st.markdown( - f"- After final round ({stats['max_round']}): **{stats['rounds'][stats['max_round']]['cumulative_count']}/{total_tasks}** tasks passed ({stats['rounds'][stats['max_round']]['cumulative_count']/total_tasks:.0%})" - ) - else: - st.info("No task completion data available.") - - -if state.scenario is not None: - summary_window() - if st.toggle("show analyse_task_competition"): - analyze_task_completion() - - # R&D Loops Window - if isinstance(state.scenario, SIMILAR_SCENARIOS): - st.header("R&D Loopsโ™พ๏ธ", divider="rainbow", anchor="_rdloops") - if len(state.msgs) > 1: - r_options = list(state.msgs.keys()) - if 0 in r_options: - r_options.remove(0) - round = st.radio("**Loops**", horizontal=True, options=r_options, index=state.lround - 1) - else: - round = 1 - - rf_c, d_c = st.columns([2, 2]) - elif isinstance(state.scenario, GeneralModelScenario): - - rf_c = st.container() - d_c = st.container() - round = 0 - else: - st.error("Unknown Scenario!") - st.stop() - - with rf_c: - research_window() - feedback_window() - - with d_c.container(border=True): - evolving_window() - - -st.markdown("


", unsafe_allow_html=True) -st.markdown("#### Disclaimer") -st.markdown( - "*This content is AI-generated and may not be fully accurate or up-to-date; please verify with a professional for critical matters.*", - unsafe_allow_html=True, -) diff --git a/rdagent/log/ui/conf.py b/rdagent/log/ui/conf.py deleted file mode 100644 index 9590b935..00000000 --- a/rdagent/log/ui/conf.py +++ /dev/null @@ -1,24 +0,0 @@ -from pydantic_settings import SettingsConfigDict - -from rdagent.core.conf import ExtendedBaseSettings - - -class UIBasePropSetting(ExtendedBaseSettings): - model_config = SettingsConfigDict(env_prefix="UI_", protected_namespaces=()) - - default_log_folders: list[str] = ["./log"] - - baseline_result_path: str = "./baseline.csv" - - aide_path: str = "./aide" - - amlt_path: str = "/data/share_folder_local/amlt" - - static_path: str = "./git_ignore_folder/static" - - trace_folder: str = "./git_ignore_folder/traces" - - enable_cache: bool = True - - -UI_SETTING = UIBasePropSetting() diff --git a/rdagent/log/ui/ds_summary.py b/rdagent/log/ui/ds_summary.py deleted file mode 100755 index 0de0355c..00000000 --- a/rdagent/log/ui/ds_summary.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -Please refer to rdagent/log/ui/utils.py:get_summary_df for more detailed documents about metrics -""" - -import re -from pathlib import Path - -import pandas as pd -import plotly.express as px -import streamlit as st -from streamlit import session_state as state - -from rdagent.log.ui.utils import ( - ALL, - HIGH, - LITE, - MEDIUM, - curve_figure, - get_statistics_df, - get_summary_df, - lite_curve_figure, - percent_df, -) -from rdagent.scenarios.kaggle.kaggle_crawler import get_metric_direction - - -def curves_win(summary: dict): - # draw curves - cbwin1, cbwin2 = st.columns(2) - if cbwin1.toggle("Show Curves", key="show_curves"): - for k, v in summary.items(): - with st.container(border=True): - st.markdown(f"**:blue[{k}] - :violet[{v['competition']}]**") - try: - tscores = {k: v for k, v in v["test_scores"].items()} - tscores = pd.Series(tscores) - vscores = {} - for k, vs in v["valid_scores"].items(): - if not vs.index.is_unique: - st.warning( - f"Loop {k}'s valid scores index are not unique, only the last one will be kept to show." - ) - st.write(vs) - vscores[k] = vs[~vs.index.duplicated(keep="last")].iloc[:, 0] - if len(vscores) > 0: - metric_name = list(vscores.values())[0].name - else: - metric_name = "None" - vscores = pd.DataFrame(vscores) - if "ensemble" in vscores.index: - ensemble_row = vscores.loc[["ensemble"]] - vscores = pd.concat([ensemble_row, vscores.drop("ensemble")]) - vscores = vscores.T - vscores["test"] = tscores - vscores.index = [f"L{i}" for i in vscores.index] - vscores.columns.name = metric_name - - st.plotly_chart(curve_figure(vscores)) - except Exception as e: - import traceback - - st.markdown("- Error: " + str(e)) - st.code(traceback.format_exc()) - st.markdown("- Valid Scores: ") - # st.write({k: type(v) for k, v in v["valid_scores"].items()}) - st.json(v["valid_scores"]) - if cbwin2.toggle("Show Curves (Lite)", key="show_curves_lite"): - st.pyplot(lite_curve_figure(summary)) - - -def all_summarize_win(): - def shorten_folder_name(folder: str) -> str: - if "amlt" in folder: - return folder[folder.rfind("amlt") + 5 :].split("/")[0] - if "ep" in folder: - return folder[folder.rfind("ep") :] - return folder - - selected_folders = st.multiselect( - "Show these folders", - state.log_folders, - state.log_folders, - format_func=shorten_folder_name, - ) - for lf in selected_folders: - if not (Path(lf) / "summary.pkl").exists(): - st.warning( - f"summary.pkl not found in **{lf}**\n\nRun:`dotenv run -- python rdagent/log/mle_summary.py grade_summary --log_folder={lf} --hours=<>`" - ) - summary = {} - dfs = [] - for lf in selected_folders: - s, df = get_summary_df(lf) - df.index = [f"{shorten_folder_name(lf)} - {idx}" for idx in df.index] - - dfs.append(df) - summary.update({f"{shorten_folder_name(lf)} - {k}": v for k, v in s.items()}) - base_df = pd.concat(dfs) - - valid_rate = float(base_df.get("Valid Improve", pd.Series()).mean()) - test_rate = float(base_df.get("Test Improve", pd.Series()).mean()) - submit_merge_rate = float(base_df.get("Submit Merge", pd.Series()).mean()) - merge_sota_avg = float(base_df.get("Merge Sota", pd.Series()).mean()) - base_df = percent_df(base_df) - base_df.insert(0, "Select", True) - bt1, bt2 = st.columns(2) - select_lite_level = bt2.selectbox( - "Select MLE-Bench Competitions Level", - options=["ALL", "HIGH", "MEDIUM", "LITE"], - index=0, - key="select_lite_level", - ) - if select_lite_level != "ALL": - if select_lite_level == "HIGH": - lite_set = set(HIGH) - elif select_lite_level == "MEDIUM": - lite_set = set(MEDIUM) - elif select_lite_level == "LITE": - lite_set = set(LITE) - else: - lite_set = set() - base_df["Select"] = base_df["Competition"].isin(lite_set) - else: - base_df["Select"] = True # select all if ALL is chosen - - if bt1.toggle("Select Best", key="select_best"): - - def apply_func(cdf: pd.DataFrame): - cp = base_df.loc[cdf.index[0], "Competition"] - md = get_metric_direction(cp) - # If SOTA Exp Score (valid, to_submit) column is empty, return the first index - if cdf["SOTA Exp Score (valid, to_submit)"].dropna().empty: - return cdf.index[0] - if md: - best_idx = cdf["SOTA Exp Score (valid, to_submit)"].idxmax() - else: - best_idx = cdf["SOTA Exp Score (valid, to_submit)"].idxmin() - return best_idx - - best_idxs = base_df.groupby("Competition").apply(apply_func, include_groups=False) - base_df["Select"] = base_df.index.isin(best_idxs.values) - - base_df = st.data_editor( - base_df, - column_config={ - "Select": st.column_config.CheckboxColumn("Select", help="Stat this trace.", disabled=False), - }, - disabled=(col for col in base_df.columns if col not in ["Select"]), - ) - st.markdown("Ours vs Base: `math.exp(abs(math.log(sota_exp_score / baseline_score)))`") - - # ็ปŸ่ฎก้€‰ๆ‹ฉ็š„ๆฏ”่ต› - base_df = base_df[base_df["Select"]] - st.markdown(f"**็ปŸ่ฎก็š„ๆฏ”่ต›ๆ•ฐ็›ฎ: :red[{base_df.shape[0]}]**") - stat_win_left, stat_win_right = st.columns(2) - with stat_win_left: - stat_df = get_statistics_df(base_df) - st.dataframe(stat_df.round(2)) - markdown_table = f""" -| xxx | {stat_df.iloc[0,1]:.1f} | {stat_df.iloc[1,1]:.1f} | {stat_df.iloc[2,1]:.1f} | {stat_df.iloc[3,1]:.1f} | {stat_df.iloc[4,1]:.1f} | {stat_df.iloc[5,1]:.1f} | {stat_df.iloc[6,1]:.1f} | -| Valid Improve {valid_rate * 100:.2f}% | Test Improve {test_rate * 100:.2f}% | Submit Merge {submit_merge_rate * 100:.2f}% | Merge Sota {merge_sota_avg * 100:.2f}% | -""" - st.text(markdown_table) - with stat_win_right: - Loop_counts = base_df["Total Loops"] - - # Create histogram - fig = px.histogram( - Loop_counts, nbins=15, title="Distribution of Total Loops", color_discrete_sequence=["#3498db"] - ) - fig.update_layout(title_font_size=16, title_font_color="#2c3e50") - - # Calculate statistics - mean_value = Loop_counts.mean() - median_value = Loop_counts.median() - - # Add mean and median lines - fig.add_vline(x=mean_value, line_color="#e74c3c", line_width=3) - fig.add_vline(x=median_value, line_color="#f39c12", line_width=3) - - fig.add_annotation( - x=0.02, - y=0.95, - xref="paper", - yref="paper", - text=f"Mean: {mean_value:.1f}
Median: {median_value:.1f}", - showarrow=False, - bgcolor="rgba(255,255,255,0.9)", - bordercolor="rgba(128,128,128,0.5)", - borderwidth=1, - font=dict(size=12, color="#333333"), - ) - - st.plotly_chart(fig, use_container_width=True) - - # write curve - st.subheader("Curves", divider="rainbow") - curves_win(summary) - - -with st.container(border=True): - try: - all_summarize_win() - except Exception as e: - import traceback - - st.error(f"Error occurred when show summary:\n{e}") - st.code(traceback.format_exc()) diff --git a/rdagent/log/ui/ds_trace.py b/rdagent/log/ui/ds_trace.py deleted file mode 100644 index ec70f13f..00000000 --- a/rdagent/log/ui/ds_trace.py +++ /dev/null @@ -1,1205 +0,0 @@ -import hashlib -import json -import pickle -import random -import re -from collections import defaultdict -from datetime import time, timedelta -from pathlib import Path - -import pandas as pd -import plotly.express as px -import streamlit as st -from litellm import get_valid_models -from streamlit import session_state as state - -from rdagent.app.data_science.loop import DataScienceRDLoop -from rdagent.log.storage import FileStorage -from rdagent.log.ui.conf import UI_SETTING -from rdagent.log.ui.utils import ( - curve_figure, - get_sota_exp_stat, - load_times_info, - timeline_figure, - trace_figure, -) -from rdagent.log.utils import ( - LogColors, - extract_evoid, - extract_json, - extract_loopid_func_name, - is_valid_session, -) -from rdagent.oai.backend.litellm import LITELLM_SETTINGS -from rdagent.oai.llm_utils import APIBackend - -# Import necessary classes for the response format -from rdagent.scenarios.data_science.proposal.exp_gen.proposal import ( - CodingSketch, - HypothesisList, - ScenarioChallenges, - TraceChallenges, -) -from rdagent.utils.agent.tpl import T -from rdagent.utils.repo.diff import generate_diff_from_dict - -if "show_stdout" not in state: - state.show_stdout = False -if "show_llm_log" not in state: - state.show_llm_log = False -if "data" not in state: - state.data = defaultdict(lambda: defaultdict(dict)) -if "llm_data" not in state: - state.llm_data = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) -if "log_path" not in state: - state.log_path = None -if "log_folder" not in state: - state.log_folder = Path("./log") -if "sota_info" not in state: - state.sota_info = None - -available_models = get_valid_models() -LITELLM_SETTINGS.dump_chat_cache = False -LITELLM_SETTINGS.dump_embedding_cache = False -LITELLM_SETTINGS.use_chat_cache = False -LITELLM_SETTINGS.use_embedding_cache = False - - -def convert_defaultdict_to_dict(d): - if isinstance(d, defaultdict): - d = {k: convert_defaultdict_to_dict(v) for k, v in d.items()} - return d - - -def load_data(log_path: Path): - """ - Load and normalize logged data for the UI. - - Meaning of "no_tag": - - We attempt to extract an evolution id (ei) from each message tag. - - If no ei can be extracted (i.e., the entry is not tied to a specific evolving step), - the item is stored under the "no_tag" key. - - Typical "no_tag" entries include: - * direct_exp_gen["no_tag"]: the base experiment/hypothesis for the loop - * coding["no_tag"] / running["no_tag"]: the final workspace/result for that stage - * llm_data[loop_id][function]["no_tag"]: common LLM logs without an ei - """ - data = defaultdict(lambda: defaultdict(dict)) - llm_data = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) - token_costs = defaultdict(list) - - for msg in FileStorage(log_path).iter_msg(): - if not msg.tag: - continue - li, fn = extract_loopid_func_name(msg.tag) - ei = extract_evoid(msg.tag) - if li is not None: - li = int(li) - if ei is not None: - ei = int(ei) - if "debug_" in msg.tag: - if ei is not None: - llm_data[li][fn][ei].append( - { - "tag": msg.tag, - "obj": msg.content, - } - ) - else: - llm_data[li][fn]["no_tag"].append( - { - "tag": msg.tag, - "obj": msg.content, - } - ) - elif "token_cost" in msg.tag: - token_costs[li].append(msg) - elif "llm" not in msg.tag and "session" not in msg.tag and "batch embedding" not in msg.tag: - if msg.tag == "competition": - data["competition"] = msg.content - continue - if "SETTINGS" in msg.tag: - data["settings"][msg.tag] = msg.content - continue - - msg.tag = re.sub(r"\.evo_loop_\d+", "", msg.tag) - msg.tag = re.sub(r"Loop_\d+\.[^.]+\.?", "", msg.tag) - msg.tag = msg.tag.strip() - - if ei is not None: - if ei not in data[li][fn]: - data[li][fn][ei] = {} - data[li][fn][ei][msg.tag] = msg.content - else: - if msg.tag: - data[li][fn][msg.tag] = msg.content - else: - if not isinstance(msg.content, str): - data[li][fn]["no_tag"] = msg.content - - # To be compatible with old version log trace, keep this - llm_log_p = log_path / "debug_llm.pkl" - if llm_log_p.exists(): - try: - rd = pickle.loads(llm_log_p.read_bytes()) - except: - rd = [] - for d in rd: - t = d["tag"] - if "debug_exp_gen" in t: - continue - if "debug_tpl" in t and "filter_" in d["obj"]["uri"]: - continue - lid, fn = extract_loopid_func_name(t) - ei = extract_evoid(t) - if lid: - lid = int(lid) - if ei is not None: - ei = int(ei) - - if ei is not None: - llm_data[lid][fn][ei].append(d) - else: - llm_data[lid][fn]["no_tag"].append(d) - - return ( - convert_defaultdict_to_dict(data), - convert_defaultdict_to_dict(llm_data), - convert_defaultdict_to_dict(token_costs), - ) - - -if UI_SETTING.enable_cache: - load_data = st.cache_data(persist=True)(load_data) - - -def load_stdout(stdout_path: Path): - if stdout_path.exists(): - stdout = stdout_path.read_text() - else: - stdout = f"Please Set: {stdout_path}" - return stdout - - -# UI windows -def task_win(task): - with st.expander(f"**:violet[{task.name}]**", expanded=False): - st.markdown(task.description) - if hasattr(task, "package_info"): - st.markdown(f"**:blue[Package Info:]**") - st.code(task.package_info) - if hasattr(task, "architecture"): # model task - st.markdown(f""" - | Model_type | Architecture | hyperparameters | - |------------|--------------|-----------------| - | {task.model_type} | {task.architecture} | {task.hyperparameters} | - """) - - -def workspace_win(workspace, cmp_workspace=None, cmp_name="last code."): - show_files = {k: v for k, v in workspace.file_dict.items() if "test" not in k} - if len(show_files) > 0: - if cmp_workspace: - diff = generate_diff_from_dict(cmp_workspace.file_dict, show_files, "main.py") - with st.popover(f":violet[**Diff with {cmp_name}**]", use_container_width=True, icon="๐Ÿ”"): - st.code("".join(diff), language="diff", wrap_lines=True, line_numbers=True) - - rtime = workspace.running_info.running_time - time_str = timedelta_to_str(timedelta(seconds=rtime) if rtime else None) or "00:00:00" - - with st.popover( - f"โฑ๏ธ{time_str} ๐Ÿ“‚Files in :blue[{replace_ep_path(workspace.workspace_path)}]", use_container_width=True - ): - st.write(replace_ep_path(workspace.workspace_path)) - code_tabs = st.tabs(show_files.keys()) - for ct, codename in zip(code_tabs, show_files.keys()): - with ct: - st.code( - show_files[codename], - language=("python" if codename.endswith(".py") else "markdown"), - wrap_lines=True, - line_numbers=True, - ) - - if state.show_save_input: - st.markdown("### Save All Files to Folder") - unique_key = hashlib.md5("".join(show_files.values()).encode()).hexdigest() + str( - random.randint(0, 10000) - ) - target_folder = st.text_input("Enter target folder path:", key=unique_key) - - if st.button("Save Files", key=f"save_files_button_{unique_key}"): - if target_folder.strip() == "": - st.warning("Please enter a valid folder path.") - else: - target_folder_path = Path(target_folder) - target_folder_path.mkdir(parents=True, exist_ok=True) - for filename, content in workspace.file_dict.items(): - save_path = target_folder_path / filename - save_path.parent.mkdir(parents=True, exist_ok=True) - save_path.write_text(content, encoding="utf-8") - st.success(f"All files saved to: {target_folder}") - else: - st.markdown(f"No files in :blue[{replace_ep_path(workspace.workspace_path)}]") - - -# Helper functions -def show_text(text, lang=None): - """ๆ˜พ็คบๆ–‡ๆœฌไปฃ็ ๅ—""" - if lang: - st.code(text, language=lang, wrap_lines=True, line_numbers=True) - elif "\n" in text: - st.code(text, language="python", wrap_lines=True, line_numbers=True) - else: - st.code(text, language="html", wrap_lines=True) - - -def highlight_prompts_uri(uri): - """้ซ˜ไบฎ URI ็š„ๆ ผๅผ""" - parts = uri.split(":") - if len(parts) > 1: - return f"**{parts[0]}:**:green[**{parts[1]}**]" - return f"**{uri}**" - - -def llm_log_win(llm_d: list): - def to_str_recursive(obj): - if isinstance(obj, dict): - return {k: to_str_recursive(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [to_str_recursive(v) for v in obj] - elif isinstance(obj, tuple): - return tuple(to_str_recursive(v) for v in obj) - else: - return str(obj) - - for d in llm_d: - if "debug_tpl" in d["tag"]: - uri = d["obj"]["uri"] - if "filter_redundant_text" in uri: - continue - tpl = d["obj"]["template"] - cxt = d["obj"]["context"] - rd = d["obj"]["rendered"] - with st.popover(highlight_prompts_uri(uri), icon="โš™๏ธ", use_container_width=True): - t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"]) - with t1: - show_text(rd) - with t2: - show_text(tpl, lang="django") - with t3: - st.json(to_str_recursive(cxt)) - elif "debug_llm" in d["tag"]: - system = d["obj"].get("system", None) - user = d["obj"]["user"] - resp = d["obj"]["resp"] - start_time = d["obj"].get("start", "") - end_time = d["obj"].get("end", "") - if start_time and end_time: - start_str = start_time.strftime("%m-%d %H:%M:%S") - end_str = end_time.strftime("%m-%d %H:%M:%S") - duration = end_time - start_time - time_info_str = ( - f"๐Ÿ•ฐ๏ธ:blue[**{start_str} ~ {end_str}**] โณ:violet[**{round(duration.total_seconds(), 2)}s**]" - ) - else: - time_info_str = "" - with st.expander(f"**LLM** {time_info_str}", icon="๐Ÿค–", expanded=False): - t1, t2, t3, t4 = st.tabs( - [":green[**Response**]", ":blue[**User**]", ":orange[**System**]", ":violet[**ChatBot**]"] - ) - with t1: - try: - rdict = json.loads(resp) - showed_keys = [] - for k, v in rdict.items(): - if k.endswith(".py") or k.endswith(".md"): - st.markdown(f":red[**{k}**]") - st.code(v, language="python", wrap_lines=True, line_numbers=True) - showed_keys.append(k) - for k in showed_keys: - rdict.pop(k) - if len(showed_keys) > 0: - st.write(":red[**Other parts (except for the code or spec) in response dict:**]") - st.json(rdict) - except: - show_text(resp) - with t2: - show_text(user) - with t3: - show_text(system or "No system prompt available") - with t4: - input_c, resp_c = st.columns(2) - key = hashlib.md5(resp.encode()).hexdigest() - with input_c: - btc1, btc2, btc3 = st.columns(3) - trace_model = ( - state.data.get("settings", {}) - .get("LITELLM_SETTINGS", {}) - .get("chat_model", available_models[0]) - ) - trace_reasoning_effort = ( - state.data.get("settings", {}).get("LITELLM_SETTINGS", {}).get("reasoning_effort", None) - ) - LITELLM_SETTINGS.chat_model = btc1.selectbox( - "Chat Model", - options=available_models, - index=available_models.index(trace_model), - key=key + "_chat_model", - ) - LITELLM_SETTINGS.reasoning_effort = btc2.selectbox( - "Reasoning Effort", - options=[None, "low", "medium", "high"], - index=[None, "low", "medium", "high"].index(trace_reasoning_effort), - key=key + "_reasoning_effort", - ) - rf = btc3.selectbox( - "Response Format", - options=[None, ScenarioChallenges, TraceChallenges, HypothesisList, CodingSketch], - format_func=lambda x: x.__name__ if x else "None", - key=key + "_response_format", - ) - json_mode = st.checkbox("JSON Mode", value=False, key=key + "_json_mode") - sys_p = input_c.text_area(label="system", value=system, height="content", key=key + "_system") - user_p = input_c.text_area(label="user", value=user, height="content", key=key + "_user") - with resp_c: - if st.button("Call LLM", key=key + "_call_llm"): - with st.spinner("Calling LLM..."): - try: - resp_new = APIBackend().build_messages_and_create_chat_completion( - user_prompt=user_p, - system_prompt=sys_p, - json_mode=json_mode, - response_format=rf, - ) - except Exception as e: - resp_new = f"Error: {e}" - try: # json format string - rdict = json.loads(resp_new) - st.json(rdict) - except: - try: # common string - st.code(resp_new, wrap_lines=True, line_numbers=True) - except: # response format type - st.write(resp_new) - - -def hypothesis_win(hypo): - try: - st.code(str(hypo).replace("\n", "\n\n"), wrap_lines=True) - except Exception as e: - st.write(hypo.__dict__) - - -def exp_gen_win(exp_gen_data, llm_data=None): - st.header("Exp Gen", divider="blue", anchor="exp-gen") - if state.show_llm_log and llm_data is not None: - llm_log_win(llm_data["no_tag"]) - st.subheader("๐Ÿ’ก Hypothesis") - hypothesis_win(exp_gen_data["no_tag"].hypothesis) - - st.subheader("๐Ÿ“‹ pending_tasks") - for tasks in exp_gen_data["no_tag"].pending_tasks_list: - task_win(tasks[0]) - st.subheader("๐Ÿ“ Exp Workspace") - workspace_win(exp_gen_data["no_tag"].experiment_workspace) - - -def evolving_win(data, key, llm_data=None, base_workspace=None): - with st.container(border=True): - if len(data) > 1: - evo_id = st.slider("Evolving", 0, len(data) - 1, 0, key=key) - elif len(data) == 1: - evo_id = 0 - else: - st.markdown("No evolving.") - return - - if evo_id in data: - if state.show_llm_log and llm_data is not None: - llm_log_win(llm_data[evo_id]) - - # get evolving workspace - if "evolving code" in data[evo_id] and data[evo_id]["evolving code"][0] is not None: - evolving_code_workspace = data[evo_id]["evolving code"][0] - else: - evolving_code_workspace = None - - if evolving_code_workspace is not None: - st.subheader("codes") - workspace_win( - evolving_code_workspace, - cmp_workspace=data[evo_id - 1]["evolving code"][0] if evo_id > 0 else base_workspace, - cmp_name="last evolving code" if evo_id > 0 else "base workspace", - ) - fb = data[evo_id]["evolving feedback"][0] - st.subheader("evolving feedback" + ("โœ…" if bool(fb) else "โŒ")) - f1, f2, f3, f4 = st.tabs(["execution", "return_checking", "code", "others"]) - other_attributes = { - k: v for k, v in fb.__dict__.items() if k not in ["execution", "return_checking", "code"] - } - f1.code(fb.execution, wrap_lines=True) - f2.code(fb.return_checking, wrap_lines=True) - f3.code(fb.code, wrap_lines=True) - f4.json(other_attributes) - else: - st.write("data[evo_id]['evolving code'][0] is None.") - st.write(data[evo_id]) - else: - st.markdown("No evolving.") - - -def coding_win(data, base_exp, llm_data: dict | None = None): - st.header("Coding", divider="blue", anchor="coding") - if llm_data is not None: - common_llm_data = llm_data.pop("no_tag", []) - evolving_data = {k: v for k, v in data.items() if isinstance(k, int)} - task_set = set() - for v in evolving_data.values(): - for t in v: - if "Task" in t.split(".")[0]: - task_set.add(t.split(".")[0]) - if task_set: - # ๆ–ฐ็‰ˆๅญ˜Task tag็š„Trace - for task in task_set: - st.subheader(task) - task_data = {k: {a.split(".")[1]: b for a, b in v.items() if task in a} for k, v in evolving_data.items()} - evolving_win( - task_data, - key=task, - llm_data=llm_data if llm_data else None, - base_workspace=base_exp.experiment_workspace, - ) - else: - # ๆ—ง็‰ˆๆœชๅญ˜Task tag็š„Trace - evolving_win( - evolving_data, - key="coding", - llm_data=llm_data if llm_data else None, - base_workspace=base_exp.experiment_workspace, - ) - if state.show_llm_log: - llm_log_win(common_llm_data) - if "no_tag" in data: - st.subheader("Exp Workspace (coding final)") - workspace_win(data["no_tag"].experiment_workspace) - - -def running_win(data, base_exp, llm_data=None, last_sota_exp=None): - st.header("Running", divider="blue", anchor="running") - if llm_data is not None: - common_llm_data = llm_data.pop("no_tag", []) - evolving_win( - {k: v for k, v in data.items() if isinstance(k, int)}, - key="running", - llm_data=llm_data if llm_data else None, - base_workspace=base_exp.experiment_workspace if base_exp else None, - ) - if state.show_llm_log and llm_data is not None: - llm_log_win(common_llm_data) - if "no_tag" in data: - st.subheader("Exp Workspace (running final)") - workspace_win( - data["no_tag"].experiment_workspace, - cmp_workspace=last_sota_exp.experiment_workspace if last_sota_exp else None, - cmp_name="last SOTA(to_submit)", - ) - st.subheader("Result") - try: - st.write(data["no_tag"].result) - except AttributeError as e: # Compatible with old versions - st.write(data["no_tag"].__dict__["result"]) - mle_score_text = data.get("mle_score", "no submission to score") - mle_score = extract_json(mle_score_text) - st.subheader( - "MLE Submission Score" - + ("โœ…" if (isinstance(mle_score, dict) and mle_score["score"] is not None) else "โŒ") - ) - if isinstance(mle_score, dict): - st.json(mle_score) - else: - st.code(mle_score_text, wrap_lines=True) - - -def feedback_win(fb_data, llm_data=None): - if "no_tag" not in fb_data: - st.header("Feedback", divider="orange", anchor="feedback") - return - fb = fb_data["no_tag"] - st.header("Feedback" + ("โœ…" if bool(fb) else "โŒ"), divider="orange", anchor="feedback") - if state.show_llm_log and llm_data is not None: - llm_log_win(llm_data["no_tag"]) - try: - st.code(str(fb).replace("\n", "\n\n"), wrap_lines=True) - except Exception as e: - st.write(fb.__dict__) - if fb.exception is not None: - st.markdown(f"**:red[Exception]**: {fb.exception}") - - -def sota_win(sota_exp, trace): - st.subheader("SOTA Experiment", divider="rainbow", anchor="sota-exp") - if hasattr(trace, "sota_exp_to_submit") and trace.sota_exp_to_submit is not None: - st.markdown(":orange[trace.**sota_exp_to_submit**]") - sota_exp = trace.sota_exp_to_submit - else: - st.markdown(":orange[trace.**sota_experiment()**]") - - if sota_exp: - st.markdown(f"**SOTA Exp Hypothesis**") - hypothesis_win(sota_exp.hypothesis) - st.markdown("**Exp Workspace**") - workspace_win(sota_exp.experiment_workspace) - else: - st.markdown("No SOTA experiment.") - - -def main_win(loop_id, llm_data=None): - loop_data = state.data[loop_id] - exp_gen_win(loop_data["direct_exp_gen"], llm_data["direct_exp_gen"] if llm_data else None) - if "coding" in loop_data: - coding_win( - loop_data["coding"], - base_exp=loop_data["direct_exp_gen"]["no_tag"], - llm_data=llm_data["coding"] if llm_data else None, - ) - if "running" in loop_data: - # get last SOTA_exp_to_submit - last_sota_exp = None - if "record" in loop_data: - current_trace = loop_data["record"]["trace"] - current_selection = current_trace.get_current_selection() - if len(current_selection) > 0: # TODO: Why current_selection can be "()"? - current_idx = current_selection[0] - parent_idxs = current_trace.get_parents(current_idx) - if len(parent_idxs) >= 2 and hasattr(current_trace, "idx2loop_id"): - parent_idx = parent_idxs[-2] - parent_loop_id = current_trace.idx2loop_id[parent_idx] - if parent_loop_id in state.data: - # in some cases, the state.data is synthesized, logs does not necessarily exist - last_sota_exp = state.data[parent_loop_id]["record"].get("sota_exp_to_submit", None) - - running_win( - loop_data["running"], - base_exp=loop_data["coding"].get("no_tag", None), - llm_data=llm_data["running"] if llm_data else None, - last_sota_exp=last_sota_exp, - ) - if "feedback" in loop_data: - # Show final diff between the final workspace and the base workspace - base_workspace = loop_data["direct_exp_gen"]["no_tag"].experiment_workspace - final_workspace = None - if "running" in loop_data and "no_tag" in loop_data["running"]: - final_workspace = loop_data["running"]["no_tag"].experiment_workspace - elif "coding" in loop_data and "no_tag" in loop_data["coding"]: - final_workspace = loop_data["coding"]["no_tag"].experiment_workspace - - if final_workspace is not None and base_workspace is not None: - st.subheader("Final Diff") - workspace_win(final_workspace, cmp_workspace=base_workspace, cmp_name="base workspace") - - feedback_win(loop_data["feedback"], llm_data.get("feedback", None) if llm_data else None) - if "record" in loop_data and "SOTA experiment" in loop_data["record"]: - st.header("Record", divider="violet", anchor="record") - if state.show_llm_log and llm_data is not None and "record" in llm_data: - llm_log_win(llm_data["record"]["no_tag"]) - sota_win(loop_data["record"]["SOTA experiment"], loop_data["record"]["trace"]) - - -def replace_ep_path(p: Path): - # ๆ›ฟๆขworkspace pathไธบๅฏนๅบ”epๆœบๅ™จmountๅœจep03็š„path - # TODO: FIXME: ไฝฟ็”จ้…็ฝฎ้กนๆฅๅค„็† - match = re.search(r"ep\d+", str(state.log_folder)) - if match: - ep = match.group(0) - return Path( - str(p).replace("repos/RD-Agent-Exp", f"repos/batch_ctrl/all_projects/{ep}").replace("/Data", "/data") - ) - return p - - -def get_llm_call_stats(llm_data: dict) -> tuple[int, int]: - total_llm_call = 0 - total_filter_call = 0 - total_call_duration = timedelta() - filter_call_duration = timedelta() - filter_sys_prompt = T("rdagent.utils.prompts:filter_redundant_text.system").r() - for li, loop_d in llm_data.items(): - for fn, loop_fn_d in loop_d.items(): - for k, v in loop_fn_d.items(): - for d in v: - if "debug_llm" in d["tag"]: - total_llm_call += 1 - total_call_duration += d["obj"].get("end", timedelta()) - d["obj"].get("start", timedelta()) - if "system" in d["obj"] and filter_sys_prompt == d["obj"]["system"]: - total_filter_call += 1 - filter_call_duration += d["obj"].get("end", timedelta()) - d["obj"].get( - "start", timedelta() - ) - - return total_llm_call, total_filter_call, total_call_duration, filter_call_duration - - -def get_timeout_stats(llm_data: dict): - timeout_stat = { - "coding": { - "total": 0, - "timeout": 0, - }, - "running": { - "total": 0, - "timeout": 0, - }, - } - for li, loop_d in llm_data.items(): - for fn, loop_fn_d in loop_d.items(): - for k, v in loop_fn_d.items(): - for d in v: - if "debug_tpl" in d["tag"] and "eval.user" in d["obj"]["uri"] and "stdout" in d["obj"]["context"]: - stdout = d["obj"]["context"]["stdout"] - if "The running time exceeds" in stdout: # Timeout case - timeout_stat[fn]["timeout"] += 1 - timeout_stat[fn]["total"] += 1 - - return timeout_stat - - -def timedelta_to_str(td: timedelta | None) -> str: - if isinstance(td, timedelta): - total_seconds = int(td.total_seconds()) - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - seconds = total_seconds % 60 - return f"{hours:02d}:{minutes:02d}:{seconds:02d}" - return td - - -def summarize_win(): - st.header("Summary", divider="rainbow") - with st.container(border=True): - min_id, max_id = get_state_data_range(state.data) - info0, info1, info2, info3, info4, info5, info6, info7 = st.columns(8) - show_trace_dag = info0.toggle("Show trace DAG", key="show_trace_dag") - only_success = info0.toggle("Only Success", key="only_success") - with info1.popover("LITELLM", icon="โš™๏ธ"): - st.write(state.data.get("settings", {}).get("LITELLM_SETTINGS", "No settings found.")) - with info2.popover("RD_AGENT", icon="โš™๏ธ"): - st.write(state.data.get("settings", {}).get("RD_AGENT_SETTINGS", "No settings found.")) - with info3.popover("RDLOOP", icon="โš™๏ธ"): - st.write(state.data.get("settings", {}).get("RDLOOP_SETTINGS", "No settings found.")) - - llm_call, llm_filter_call, llm_call_duration, filter_call_duration = get_llm_call_stats(state.llm_data) - info4.metric("LLM Calls", llm_call, help=timedelta_to_str(llm_call_duration)) - info5.metric( - "LLM Filter Calls", - llm_filter_call, - help=timedelta_to_str(filter_call_duration), - ) - - timeout_stats = get_timeout_stats(state.llm_data) - coding_timeout_pct = ( - round(timeout_stats["coding"]["timeout"] / timeout_stats["coding"]["total"] * 100, 2) - if timeout_stats["coding"]["total"] > 0 - else 0 - ) - info6.metric( - "Timeouts (C)", - f"{coding_timeout_pct}%", - help=f"{timeout_stats['coding']['timeout']}/{timeout_stats['coding']['total']}", - ) - running_timeout_pct = ( - round(timeout_stats["running"]["timeout"] / timeout_stats["running"]["total"] * 100, 2) - if timeout_stats["running"]["total"] > 0 - else 0 - ) - info7.metric( - "Timeouts (R)", - f"{running_timeout_pct}%", - help=f"{timeout_stats['running']['timeout']}/{timeout_stats['running']['total']}", - ) - - final_trace = list(FileStorage(state.log_folder / state.log_path).iter_msg(tag="record.trace"))[-1].content - if show_trace_dag: - st.markdown("### Trace DAG") - merge_loops = [] - for loop_id in state.llm_data.keys(): - if "direct_exp_gen" not in state.llm_data[loop_id]: - continue - if "scenarios.data_science.proposal.exp_gen.merge" in "".join( - [i["obj"]["uri"] for i in state.llm_data[loop_id]["direct_exp_gen"]["no_tag"] if "uri" in i["obj"]] - ): - merge_loops.append(loop_id) - st.pyplot(trace_figure(final_trace, merge_loops)) - - # Find all root nodes (for grouping loops by trace) - root_nodes = {} - parent_nodes = {} - for node in range(len(final_trace.hist)): - parents = final_trace.get_parents(node) - root_nodes[node] = parents[0] - parent_nodes[node] = parents[-2] if len(parents) > 1 else None - if hasattr(final_trace, "idx2loop_id"): - root_nodes = {final_trace.idx2loop_id[n]: final_trace.idx2loop_id[r] for n, r in root_nodes.items()} - parent_nodes = { - final_trace.idx2loop_id[n]: final_trace.idx2loop_id[r] if r is not None else r - for n, r in parent_nodes.items() - } - - # Generate Summary Table - df = pd.DataFrame( - columns=[ - "Root N", - "Parent N", - "Component", - "Hypothesis", - "Reason", - "Others", - "Run Score (valid)", - "Run Score (test)", - "Feedback", - "e-loops(c)", - "e-loops(r)", - "COST($)", - "Time", - "Exp Gen", - "Coding", - "Running", - ], - index=range(min_id, max_id + 1), - ) - - valid_results = {} - sota_loop_id = state.sota_info[1] if state.sota_info else None - for loop in range(min_id, max_id + 1): - loop_data = state.data[loop] - df.loc[loop, "Parent N"] = parent_nodes.get(loop, None) - df.loc[loop, "Root N"] = root_nodes.get(loop, None) - df.loc[loop, "Component"] = loop_data["direct_exp_gen"]["no_tag"].hypothesis.component - df.loc[loop, "Hypothesis"] = loop_data["direct_exp_gen"]["no_tag"].hypothesis.hypothesis - df.loc[loop, "Reason"] = loop_data["direct_exp_gen"]["no_tag"].hypothesis.reason - df.at[loop, "Others"] = { - k: v - for k, v in loop_data["direct_exp_gen"]["no_tag"].hypothesis.__dict__.items() - if k not in ["component", "hypothesis", "reason"] and v is not None - } - # In the test before 0.8.0 release, we found that when running `ui` of `data_science` (custom dataset), - # when `loop=0`, it doesn't exist in `state.token_costs.keys`, and we will get `KeyError` when running it, - # so we have fixed the problem with this dirty method for the time being. - if loop in state.token_costs: - df.loc[loop, "COST($)"] = sum(tc.content["cost"] for tc in state.token_costs[loop]) - - # Time Stats - exp_gen_time = timedelta() - coding_time = timedelta() - running_time = timedelta() - all_steps_time = timedelta() - if loop in state.times: - for step_name, step_time in state.times[loop].items(): - step_duration = step_time["end_time"] - step_time["start_time"] - if step_name == "exp_gen": - exp_gen_time += step_duration - all_steps_time += step_duration - elif step_name == "coding": - coding_time += step_duration - all_steps_time += step_duration - elif step_name == "running": - running_time += step_duration - all_steps_time += step_duration - elif step_name in ["feedback", "record"]: - all_steps_time += step_duration - df.loc[loop, "Time"] = timedelta_to_str(all_steps_time) - df.loc[loop, "Exp Gen"] = timedelta_to_str(exp_gen_time) - df.loc[loop, "Coding"] = timedelta_to_str(coding_time) - df.loc[loop, "Running"] = timedelta_to_str(running_time) - - if "running" in loop_data and "no_tag" in loop_data["running"]: - try: - try: - running_result = loop_data["running"]["no_tag"].result - except AttributeError as e: # Compatible with old versions - running_result = loop_data["running"]["no_tag"].__dict__["result"] - df.loc[loop, "Run Score (valid)"] = str(round(running_result.loc["ensemble"].iloc[0], 5)) - valid_results[loop] = running_result - except: - df.loc[loop, "Run Score (valid)"] = "โŒ" - if "mle_score" not in state.data[loop]: - if "mle_score" in loop_data["running"]: - mle_score_txt = loop_data["running"]["mle_score"] - state.data[loop]["mle_score"] = extract_json(mle_score_txt) - if ( - state.data[loop]["mle_score"] is not None - and state.data[loop]["mle_score"]["score"] is not None - ): - medal_emoji = ( - "๐Ÿฅ‡" - if state.data[loop]["mle_score"]["gold_medal"] - else ( - "๐Ÿฅˆ" - if state.data[loop]["mle_score"]["silver_medal"] - else "๐Ÿฅ‰" if state.data[loop]["mle_score"]["bronze_medal"] else "" - ) - ) - df.loc[loop, "Run Score (test)"] = f"{medal_emoji} {state.data[loop]['mle_score']['score']}" - else: - state.data[loop]["mle_score"] = mle_score_txt - df.loc[loop, "Run Score (test)"] = "โŒ" - else: - mle_score_path = ( - replace_ep_path(loop_data["running"]["no_tag"].experiment_workspace.workspace_path) - / "mle_score.txt" - ) - try: - mle_score_txt = mle_score_path.read_text() - state.data[loop]["mle_score"] = extract_json(mle_score_txt) - if state.data[loop]["mle_score"]["score"] is not None: - medal_emoji = ( - "๐Ÿฅ‡" - if state.data[loop]["mle_score"]["gold_medal"] - else ( - "๐Ÿฅˆ" - if state.data[loop]["mle_score"]["silver_medal"] - else "๐Ÿฅ‰" if state.data[loop]["mle_score"]["bronze_medal"] else "" - ) - ) - df.loc[loop, "Run Score (test)"] = ( - f"{medal_emoji} {state.data[loop]['mle_score']['score']}" - ) - else: - state.data[loop]["mle_score"] = mle_score_txt - df.loc[loop, "Run Score (test)"] = "โŒ" - except Exception as e: - state.data[loop]["mle_score"] = str(e) - df.loc[loop, "Run Score (test)"] = "โŒ" - else: - if isinstance(state.data[loop]["mle_score"], dict): - medal_emoji = ( - "๐Ÿฅ‡" - if state.data[loop]["mle_score"]["gold_medal"] - else ( - "๐Ÿฅˆ" - if state.data[loop]["mle_score"]["silver_medal"] - else "๐Ÿฅ‰" if state.data[loop]["mle_score"]["bronze_medal"] else "" - ) - ) - df.loc[loop, "Run Score (test)"] = f"{medal_emoji} {state.data[loop]['mle_score']['score']}" - else: - df.loc[loop, "Run Score (test)"] = "โŒ" - - else: - df.loc[loop, "Run Score (valid)"] = "N/A" - df.loc[loop, "Run Score (test)"] = "N/A" - - if "coding" in loop_data: - if len([i for i in loop_data["coding"].keys() if isinstance(i, int)]) == 0: - df.loc[loop, "e-loops(c)"] = 0 - else: - df.loc[loop, "e-loops(c)"] = max(i for i in loop_data["coding"].keys() if isinstance(i, int)) + 1 - if "running" in loop_data: - if len([i for i in loop_data["running"].keys() if isinstance(i, int)]) == 0: - df.loc[loop, "e-loops(r)"] = 0 - else: - df.loc[loop, "e-loops(r)"] = max(i for i in loop_data["running"].keys() if isinstance(i, int)) + 1 - if "feedback" in loop_data: - fb_emoji_str = ( - "โœ…" if "no_tag" in loop_data["feedback"] and bool(loop_data["feedback"]["no_tag"]) else "โŒ" - ) - if sota_loop_id == loop: - fb_emoji_str += " (๐Ÿ’–SOTA)" - df.loc[loop, "Feedback"] = fb_emoji_str - else: - df.loc[loop, "Feedback"] = "N/A" - - if only_success: - df = df[df["Feedback"].str.contains("โœ…", na=False)] - - # Add color styling based on root_nodes - def style_dataframe_by_root(df, root_nodes): - # Create a color map for different root nodes - using colors that work well in both light and dark modes - unique_roots = list(set(root_nodes.values())) - colors = [ - "rgba(255, 99, 132, 0.3)", - "rgba(54, 162, 235, 0.3)", - "rgba(75, 192, 75, 0.3)", - "rgba(255, 159, 64, 0.3)", - "rgba(153, 102, 255, 0.2)", - "rgba(255, 205, 86, 0.2)", - "rgba(199, 199, 199, 0.2)", - "rgba(83, 102, 255, 0.2)", - ] - root_color_map = {root: colors[i % len(colors)] for i, root in enumerate(unique_roots)} - - # Create styling function - def apply_color(row): - loop_id = row.name - if loop_id in root_nodes: - root_id = root_nodes[loop_id] - color = root_color_map.get(root_id, "rgba(128, 128, 128, 0.1)") - return [f"background-color: {color}"] * len(row) - return [""] * len(row) - - return df.style.apply(apply_color, axis=1) - - styled_df = style_dataframe_by_root( - df[df.columns[~df.columns.isin(["Hypothesis", "Reason", "Others"])]], root_nodes - ) - st.dataframe(styled_df) - - # timeline figure - if state.times: - with st.popover("Timeline", icon="โฑ๏ธ", use_container_width=True): - st.plotly_chart(timeline_figure(state.times)) - - # scores curve - vscores = {} - for k, vs in valid_results.items(): - if not vs.index.is_unique: - st.warning(f"Loop {k}'s valid scores index are not unique, only the last one will be kept to show.") - st.write(vs) - vscores[k] = vs[~vs.index.duplicated(keep="last")].iloc[:, 0] - if len(vscores) > 0: - metric_name = list(vscores.values())[0].name - else: - metric_name = "None" - vscores = pd.DataFrame(vscores) - if "ensemble" in vscores.index: - ensemble_row = vscores.loc[["ensemble"]] - vscores = pd.concat([ensemble_row, vscores.drop("ensemble")]) - vscores = vscores.T - test_scores = df["Run Score (test)"].str.replace(r"[๐Ÿฅ‡๐Ÿฅˆ๐Ÿฅ‰]\s*", "", regex=True) - vscores["test"] = test_scores - vscores.index = [f"L{i}" for i in vscores.index] - vscores.columns.name = metric_name - with st.popover("Scores Curve", icon="๐Ÿ“ˆ", use_container_width=True): - st.plotly_chart(curve_figure(vscores)) - - st.markdown("### Hypotheses Table") - hypotheses_df = df.iloc[:, :8].copy() - others_expanded = pd.json_normalize(hypotheses_df["Others"].fillna({})) - others_expanded.index = hypotheses_df.index - - hypotheses_df = hypotheses_df.drop("Others", axis=1) - hypotheses_df = hypotheses_df.drop("Parent N", axis=1) - hypotheses_df = pd.concat([hypotheses_df.iloc[:, :4], others_expanded, hypotheses_df.iloc[:, 4:]], axis=1) - - styled_hypotheses_table = style_dataframe_by_root(hypotheses_df, root_nodes) - st.dataframe( - styled_hypotheses_table, - row_height=100, - column_config={ - k: st.column_config.TextColumn( - k, - width=( - "small" - if k - in ["Component", "Root N", "Parent N", "Run Score (valid)", "Run Score (test)", "problem_label"] - else "medium" - ), - ) - for k in hypotheses_df.columns - }, - ) - - def comp_stat_func(x: pd.DataFrame): - total_num = x.shape[0] - valid_num = x[x["Run Score (test)"] != "N/A"].shape[0] - success_num = x[x["Feedback"] == "โœ…"].shape[0] - avg_e_loops = x["e-loops(c)"].mean() - return pd.Series( - { - "Loop Num": total_num, - "Valid Loop": valid_num, - "Success Loop": success_num, - "Valid Rate": round(valid_num / total_num * 100, 2), - "Success Rate": round(success_num / total_num * 100, 2), - "Avg e-loops(c)": round(avg_e_loops, 2), - } - ) - - st1, st2 = st.columns([1, 1]) - - # component statistics - comp_df = ( - df.loc[:, ["Component", "Run Score (test)", "Feedback", "e-loops(c)"]] - .groupby("Component") - .apply(comp_stat_func, include_groups=False) - ) - comp_df.loc["Total"] = comp_df.sum() - comp_df.loc["Total", "Valid Rate"] = round( - comp_df.loc["Total", "Valid Loop"] / comp_df.loc["Total", "Loop Num"] * 100, 2 - ) - comp_df.loc["Total", "Success Rate"] = round( - comp_df.loc["Total", "Success Loop"] / comp_df.loc["Total", "Loop Num"] * 100, 2 - ) - comp_df["Valid Rate"] = comp_df["Valid Rate"].apply(lambda x: f"{x}%") - comp_df["Success Rate"] = comp_df["Success Rate"].apply(lambda x: f"{x}%") - comp_df.loc["Total", "Avg e-loops(c)"] = round(df["e-loops(c)"].mean(), 2) - with st2.popover("Component Statistics", icon="๐Ÿ“Š", use_container_width=True): - st.dataframe(comp_df) - - # component time statistics - time_df = df.loc[:, ["Component", "Time", "Exp Gen", "Coding", "Running"]] - time_df = time_df.astype( - { - "Time": "timedelta64[ns]", - "Exp Gen": "timedelta64[ns]", - "Coding": "timedelta64[ns]", - "Running": "timedelta64[ns]", - } - ) - time_stat_df = time_df.groupby("Component").sum() - time_stat_df.loc["Total"] = time_stat_df.sum() - time_stat_df.loc[:, "Exp Gen(%)"] = (time_stat_df["Exp Gen"] / time_stat_df["Time"] * 100).round(2) - time_stat_df.loc[:, "Coding(%)"] = (time_stat_df["Coding"] / time_stat_df["Time"] * 100).round(2) - time_stat_df.loc[:, "Running(%)"] = (time_stat_df["Running"] / time_stat_df["Time"] * 100).round(2) - for col in ["Time", "Exp Gen", "Coding", "Running"]: - time_stat_df[col] = time_stat_df[col].map(timedelta_to_str) - with st1.popover("Time Statistics", icon="โฑ๏ธ", use_container_width=True): - st.dataframe(time_stat_df) - - # COST curve - costs = df["COST($)"].astype(float) - costs.index = [f"L{i}" for i in costs.index] - cumulative_costs = costs.cumsum() - with st.popover("COST Curve", icon="๐Ÿ’ฐ", use_container_width=True): - fig = px.line( - x=costs.index, - y=[costs.values, cumulative_costs.values], - labels={"x": "Loop", "value": "COST($)"}, - title="COST($) per Loop & Cumulative COST($)", - markers=True, - ) - fig.update_traces(mode="lines+markers") - fig.data[0].name = "COST($) per Loop" - fig.data[1].name = "Cumulative COST($)" - st.plotly_chart(fig) - - -def stdout_win(loop_id: int): - stdout = load_stdout(state.log_folder / f"{state.log_path}.stdout") - if stdout.startswith("Please Set"): - st.toast(stdout, icon="๐ŸŸก") - return - start_index = stdout.find(f"Start Loop {loop_id}") - end_index = stdout.find(f"Start Loop {loop_id + 1}") - loop_stdout = LogColors.remove_ansi_codes(stdout[start_index:end_index]) - with st.container(border=True): - st.subheader(f"Loop {loop_id} stdout") - pattern = f"Start Loop {loop_id}, " + r"Step \d+: \w+" - matches = re.finditer(pattern, loop_stdout) - step_stdouts = {} - for match in matches: - step = match.group(0) - si = match.start() - ei = loop_stdout.find(f"Start Loop {loop_id}", match.end()) - step_stdouts[step] = loop_stdout[si:ei].strip() - - for k, v in step_stdouts.items(): - with st.expander(k, expanded=False): - st.code(v, language="log", wrap_lines=True) - - -def get_folders_sorted(log_path, sort_by_time=False): - """ - Cache and return the sorted list of folders, with progress printing. - :param log_path: Log path - :param sort_by_time: Whether to sort by time, default False (sort by name) - """ - if not log_path.exists(): - st.toast(f"Path {log_path} does not exist!") - return [] - with st.spinner("Loading folder list..."): - folders = [folder for folder in log_path.iterdir() if is_valid_session(folder)] - if sort_by_time: - folders = sorted(folders, key=lambda folder: folder.stat().st_mtime, reverse=True) - else: - folders = sorted(folders, key=lambda folder: folder.name) - return [folder.name for folder in folders] - - -# UI - Sidebar -with st.sidebar: - # TODO: ๅชๆ˜ฏไธดๆ—ถ็š„ๅŠŸ่ƒฝ - if any("log.srv" in folder for folder in state.log_folders): - day_map = {"srv": "ๆœ€่ฟ‘(srv)", "srv2": "ไธŠไธ€ๆ‰น(srv2)", "srv3": "ไธŠไธŠๆ‰น(srv3)"} - day_srv = st.radio("้€‰ๆ‹ฉๆ‰นๆฌก", ["srv", "srv2", "srv3"], format_func=lambda x: day_map[x], horizontal=True) - if day_srv == "srv": - state.log_folders = [re.sub(r"log\.srv\d*", "log.srv", folder) for folder in state.log_folders] - elif day_srv == "srv2": - state.log_folders = [re.sub(r"log\.srv\d*", "log.srv2", folder) for folder in state.log_folders] - elif day_srv == "srv3": - state.log_folders = [re.sub(r"log\.srv\d*", "log.srv3", folder) for folder in state.log_folders] - - if "log_folder" in st.query_params: - state.log_folder = Path(st.query_params["log_folder"]) - state.log_folders = [str(state.log_folder)] - else: - state.log_folder = Path( - st.radio( - f"Select :blue[**one log folder**]", - state.log_folders, - format_func=lambda x: x[x.rfind("amlt") + 5 :].split("/")[0] if "amlt" in x else x, - ) - ) - if not state.log_folder.exists(): - st.warning(f"Path {state.log_folder} does not exist!") - else: - folders = get_folders_sorted(state.log_folder, sort_by_time=False) - if "selection" in st.query_params: - default_index = ( - folders.index(st.query_params["selection"]) if st.query_params["selection"] in folders else 0 - ) - else: - default_index = 0 - state.log_path = st.selectbox( - f"Select from :blue[**{state.log_folder.absolute()}**]", folders, index=default_index - ) - - if st.button("Refresh Data"): - if state.log_path is None: - st.toast("Please select a log path first!", icon="๐ŸŸก") - st.stop() - - state.times = load_times_info(state.log_folder / state.log_path) - state.data, state.llm_data, state.token_costs = load_data(state.log_folder / state.log_path) - state.sota_info = get_sota_exp_stat(Path(state.log_folder) / state.log_path, selector="auto") - st.rerun() - st.toggle("**Show LLM Log**", key="show_llm_log") - st.toggle("*Show stdout*", key="show_stdout") - st.toggle("*Show save workspace*", key="show_save_input") - st.markdown(f""" -- [Summary](#summary) -- [Exp Gen](#exp-gen) -- [Coding](#coding) -- [Running](#running) -- [Feedback](#feedback) -- [Record](#record) - - [SOTA Experiment](#sota-exp) -""") - - -def get_state_data_range(state_data): - # we have a "competition" key in state_data - # like dict_keys(['competition', 10, 11, 12, 13, 14]) - keys = [ - k - for k in state_data.keys() - if isinstance(k, int) and "direct_exp_gen" in state_data[k] and "no_tag" in state_data[k]["direct_exp_gen"] - ] - return min(keys), max(keys) - - -# UI - Main -if "competition" in state.data: - st.title( - state.data["competition"] - + f" ([share_link](/ds_trace?log_folder={state.log_folder}&selection={state.log_path}))" - ) - summarize_win() - min_id, max_id = get_state_data_range(state.data) - if max_id > min_id: - loop_id = st.slider("Loop", min_id, max_id, min_id) - else: - loop_id = min_id - if state.show_stdout: - stdout_win(loop_id) - main_win(loop_id, state.llm_data[loop_id] if loop_id in state.llm_data else None) diff --git a/rdagent/log/ui/ds_user_interact.py b/rdagent/log/ui/ds_user_interact.py deleted file mode 100644 index c277ac83..00000000 --- a/rdagent/log/ui/ds_user_interact.py +++ /dev/null @@ -1,172 +0,0 @@ -import json -import pickle -import time -from datetime import datetime, timedelta -from pathlib import Path - -import streamlit as st -from streamlit import session_state as state - -from rdagent.app.data_science.conf import DS_RD_SETTING - -st.set_page_config(layout="wide", page_title="RD-Agent_user_interact", page_icon="๐ŸŽ“", initial_sidebar_state="expanded") - -# ๅˆๅง‹ๅŒ–session state -if "sessions" not in state: - state.sessions = {} -if "selected_session_name" not in state: - state.selected_session_name = None - - -def render_main_content(): - """ๆธฒๆŸ“ไธป่ฆๅ†…ๅฎนๅŒบๅŸŸ""" - if state.selected_session_name is not None and state.selected_session_name in state.sessions: - selected_session_data = state.sessions[state.selected_session_name] - if selected_session_data is not None: - st.title( - f"Session: {state.selected_session_name[:4]} with competition {selected_session_data['competition']}" - ) - st.title("Contextual Information:") - st.subheader("Competition scenario:", divider=True) - scenario = st.code(selected_session_data["scenario_description"], language="yaml") - st.subheader("Former attempts summary:", divider=True) - scenario = st.code(selected_session_data["ds_trace_desc"], language="yaml") - if selected_session_data["current_code"] != "": - st.subheader("Current SOTA code", divider=True) - scenario = st.code( - body=selected_session_data["current_code"], - language="python", - ) - - st.subheader("Hypothesis candidates:", divider=True) - hypothesis_candidates = selected_session_data["hypothesis_candidates"] - tabs = st.tabs( - [ - f"{'โœ…' if i == selected_session_data['target_hypothesis_index'] or selected_session_data['target_hypothesis_index'] == -1 else ''}Hypothesis {i+1}" - for i in range(len(hypothesis_candidates)) - ] - ) - for index, hypothesis in enumerate(hypothesis_candidates): - with tabs[index]: - st.code(str(hypothesis), language="yaml") - st.text("โœ… means picked as target hypothesis") - - st.title("Decisions to make:") - - with st.form(key="user_form"): - st.caption("Please modify the fields below and submit to provide your feedback.") - target_hypothesis = st.text_area( - "Target hypothesis: (you can copy from candidates)", - value=(original_hypothesis := selected_session_data["target_hypothesis"].hypothesis), - height="content", - ) - target_task = st.text_area( - "Target task description:", - value=(original_task_desc := selected_session_data["task"].description), - height="content", - ) - original_user_instruction = selected_session_data.get("user_instruction") - user_instruction_list = [] - if selected_session_data.get("former_user_instructions") is not None: - st.caption( - "Former user instructions, you can modify or delete the content to remove certain instruction." - ) - for user_instruction in selected_session_data.get("former_user_instructions"): - user_instruction_list.append( - st.text_area("Former user instruction", value=user_instruction, height="content") - ) - user_instruction_list.append(st.text_area("Add new user instruction", value="", height="content")) - submit = st.form_submit_button("Submit") - approve = st.form_submit_button("Approve without changes") - - if submit or approve: - if approve: - submit_dict = { - "action": "confirm", - } - else: - user_instruction_str_list = [ui for ui in user_instruction_list if ui.strip() != ""] - user_instruction_str_list = ( - None if len(user_instruction_str_list) == 0 else user_instruction_str_list - ) - action = ( - "confirm" - if target_hypothesis == original_hypothesis - and target_task == original_task_desc - and user_instruction_str_list == original_user_instruction - else "rewrite" - ) - submit_dict = { - "target_hypothesis": target_hypothesis, - "task_description": target_task, - "user_instruction": user_instruction_str_list, - "action": action, - } - json.dump( - submit_dict, - open( - DS_RD_SETTING.user_interaction_mid_folder / f"{state.selected_session_name}_RET.json", "w" - ), - ) - Path(DS_RD_SETTING.user_interaction_mid_folder / f"{state.selected_session_name}.pkl").unlink( - missing_ok=True - ) - st.success("Your feedback has been submitted. Thank you!") - time.sleep(5) - state.selected_session_name = None - - if st.button("Extend expiration by 60s"): - session_data = pickle.load( - open(DS_RD_SETTING.user_interaction_mid_folder / f"{state.selected_session_name}.pkl", "rb") - ) - session_data["expired_datetime"] = session_data["expired_datetime"] + timedelta(seconds=60) - pickle.dump( - session_data, - open(DS_RD_SETTING.user_interaction_mid_folder / f"{state.selected_session_name}.pkl", "wb"), - ) - else: - st.warning("Please select a session from the sidebar.") - - -# ๆฏ็ง’ๆ›ดๆ–ฐไธ€ๆฌกsessions -@st.fragment(run_every=1) -def update_sessions(): - log_folder = Path(DS_RD_SETTING.user_interaction_mid_folder) - state.sessions = {} - for session_file in log_folder.glob("*.pkl"): - try: - session_data = pickle.load(open(session_file, "rb")) - if session_data["expired_datetime"] > datetime.now(): - state.sessions[session_file.stem] = session_data - else: - session_file.unlink(missing_ok=True) - ret_file = log_folder / f"{session_file.stem}_RET.json" - ret_file.unlink(missing_ok=True) - except Exception as e: - continue - render_main_content() - - -@st.fragment(run_every=1) -def render_sidebar(): - st.title("R&D-Agent User Interaction Portal") - if state.sessions: - st.header("Active Sessions") - st.caption("Click a session to view:") - session_names = [name for name in state.sessions] - for session_name in session_names: - with st.container(border=True): - remaining = state.sessions[session_name]["expired_datetime"] - datetime.now() - total_sec = int(remaining.total_seconds()) - label = f"{total_sec}s to expire" if total_sec > 0 else "Expired" - if st.button(f"session id:{session_name[:4]}", key=f"session_btn_{session_name}"): - state.selected_session_name = session_name - state.data = state.sessions[session_name] - st.markdown(f"โณ {label}") - else: - st.warning("No active sessions available. Please wait.") - - -update_sessions() -with st.sidebar: - render_sidebar() diff --git a/rdagent/log/ui/dsapp.py b/rdagent/log/ui/dsapp.py deleted file mode 100644 index fd314972..00000000 --- a/rdagent/log/ui/dsapp.py +++ /dev/null @@ -1,51 +0,0 @@ -from pathlib import Path - -import streamlit as st -from streamlit import session_state as state - -from rdagent.app.data_science.loop import DataScienceRDLoop -from rdagent.log.ui.conf import UI_SETTING - - -def convert_log_folder_str(lf: str) -> str: - if "/" not in lf: - return f"{UI_SETTING.amlt_path}/{lf.strip()}/combined_logs" - return lf.strip() - - -def extract_amlt_name(x: str) -> str: - if "amlt" not in x: - return x - return x[x.rfind("amlt") + 5 :].split("/")[0] - - -# ่ฎพ็ฝฎไธปๆ—ฅๅฟ—่ทฏๅพ„ -if "log_folder" not in state: - state.log_folder = Path("./log") -if "log_folders" not in state: - state.log_folders = [convert_log_folder_str(i) for i in UI_SETTING.default_log_folders] - -summary_page = st.Page("ds_summary.py", title="Summary", icon="๐Ÿ“Š") -trace_page = st.Page("ds_trace.py", title="Trace", icon="๐Ÿ“ˆ") -aide_page = st.Page("aide.py", title="Aide", icon="๐Ÿง‘โ€๐Ÿซ") -st.set_page_config(layout="wide", page_title="RD-Agent", page_icon="๐ŸŽ“", initial_sidebar_state="expanded") -st.navigation([summary_page, trace_page, aide_page]).run() - - -# UI - Sidebar -with st.sidebar: - st.subheader("Pages", divider="rainbow") - st.page_link(summary_page, icon="๐Ÿ“Š") - st.page_link(trace_page, icon="๐Ÿ“ˆ") - st.page_link(aide_page, icon="๐Ÿง‘โ€๐Ÿซ") - - st.subheader("Settings", divider="rainbow") - with st.form("log_folder_form", border=False): - log_folder_str = st.text_area( - "**Log Folders**(split by ';')", value=";".join(extract_amlt_name(i) for i in state.log_folders) - ) - if st.form_submit_button("Confirm"): - state.log_folders = [ - convert_log_folder_str(folder) for folder in log_folder_str.split(";") if folder.strip() - ] - st.rerun() diff --git a/rdagent/log/ui/flow.png b/rdagent/log/ui/flow.png deleted file mode 100644 index caca2c4c00bafe961b77ea6931ccc0f10a80f1c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 125836 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�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 diff --git a/rdagent/log/ui/llm_st.py b/rdagent/log/ui/llm_st.py deleted file mode 100644 index e3a963a8..00000000 --- a/rdagent/log/ui/llm_st.py +++ /dev/null @@ -1,306 +0,0 @@ -import argparse -import json -import pickle -import re -import time -from pathlib import Path - -import streamlit as st -from streamlit import session_state - -from rdagent.log.ui.conf import UI_SETTING -from rdagent.log.utils import extract_evoid, extract_loopid_func_name - -st.set_page_config(layout="wide", page_title="debug_llm", page_icon="๐ŸŽ“", initial_sidebar_state="expanded") - -# ่Žทๅ– log_path ๅ‚ๆ•ฐ -parser = argparse.ArgumentParser(description="RD-Agent Streamlit App") -parser.add_argument("--log_dir", type=str, help="Path to the log directory") -args = parser.parse_args() - - -def get_folders_sorted(log_path): - """็ผ“ๅญ˜ๅนถ่ฟ”ๅ›žๆŽ’ๅบๅŽ็š„ๆ–‡ไปถๅคนๅˆ—่กจ๏ผŒๅนถๅŠ ๅ…ฅ่ฟ›ๅบฆๆ‰“ๅฐ""" - with st.spinner("ๆญฃๅœจๅŠ ่ฝฝๆ–‡ไปถๅคนๅˆ—่กจ..."): - folders = sorted( - (folder for folder in log_path.iterdir() if folder.is_dir() and list(folder.iterdir())), - key=lambda folder: folder.stat().st_mtime, - reverse=True, - ) - st.write(f"ๆ‰พๅˆฐ {len(folders)} ไธชๆ–‡ไปถๅคน") - return [folder.name for folder in folders] - - -if UI_SETTING.enable_cache: - get_folders_sorted = st.cache_data(get_folders_sorted) - - -# ่ฎพ็ฝฎไธปๆ—ฅๅฟ—่ทฏๅพ„ -main_log_path = Path(args.log_dir) if args.log_dir else Path("./log") -if not main_log_path.exists(): - st.error(f"Log dir {main_log_path} does not exist!") - st.stop() - -if "data" not in session_state: - session_state.data = [] -if "log_path" not in session_state: - session_state.log_path = None - -tlist = [] - - -def load_data(): - """ๅŠ ่ฝฝๆ•ฐๆฎๅˆฐ session_state ๅนถๆ˜พ็คบ่ฟ›ๅบฆ""" - log_file = main_log_path / session_state.log_path / "debug_llm.pkl" - try: - with st.spinner(f"ๆญฃๅœจๅŠ ่ฝฝๆ•ฐๆฎๆ–‡ไปถ {log_file}..."): - start_time = time.time() - with open(log_file, "rb") as f: - session_state.data = pickle.load(f) - st.success(f"ๆ•ฐๆฎๅŠ ่ฝฝๅฎŒๆˆ๏ผ่€—ๆ—ถ {time.time() - start_time:.2f} ็ง’") - st.session_state["current_loop"] = 1 - except Exception as e: - session_state.data = [{"error": str(e)}] - st.error(f"ๅŠ ่ฝฝๆ•ฐๆฎๅคฑ่ดฅ: {e}") - - -# UI - Sidebar -with st.sidebar: - st.markdown(":blue[**Log Path**]") - manually = st.toggle("Manual Input") - if manually: - st.text_input("log path", key="log_path", label_visibility="collapsed") - else: - folders = get_folders_sorted(main_log_path) - st.selectbox(f"**Select from {main_log_path.absolute()}**", folders, key="log_path") - - if st.button("Refresh Data"): - load_data() - st.rerun() - - -# Helper functions -def show_text(text, lang=None): - """ๆ˜พ็คบๆ–‡ๆœฌไปฃ็ ๅ—""" - if lang: - st.code(text, language=lang, wrap_lines=True) - elif "\n" in text: - st.code(text, language="python", wrap_lines=True) - else: - st.code(text, language="html", wrap_lines=True) - - -def highlight_prompts_uri(uri): - """้ซ˜ไบฎ URI ็š„ๆ ผๅผ""" - parts = uri.split(":") - return f"**{parts[0]}:**:green[**{parts[1]}**]" - - -# Display Data -progress_text = st.empty() -progress_bar = st.progress(0) - -# ๆฏ้กตๅฑ•็คบไธ€ไธช Loop -LOOPS_PER_PAGE = 1 - -# ่Žทๅ–ๆ‰€ๆœ‰็š„ Loop ID -loop_groups = {} -for i, d in enumerate(session_state.data): - tag = d["tag"] - loop_id, _ = extract_loopid_func_name(tag) - if loop_id: - if loop_id not in loop_groups: - loop_groups[loop_id] = [] - loop_groups[loop_id].append(d) - -# ๆŒ‰ Loop ID ๆŽ’ๅบ -sorted_loop_ids = sorted(loop_groups.keys(), key=int) # ๅ‡่ฎพ Loop ID ๆ˜ฏๆ•ฐๅญ— -total_loops = len(sorted_loop_ids) -total_pages = total_loops # ๆฏ้กตๅฑ•็คบไธ€ไธช Loop - - -# simple display -# FIXME: Delete this simple UI if trace have tag(evo_id & loop_id) -# with st.sidebar: -# start = int(st.text_input("start", 0)) -# end = int(st.text_input("end", 100)) -# for m in session_state.data[start:end]: -# if "tpl" in m["tag"]: -# obj = m["obj"] -# uri = obj["uri"] -# tpl = obj["template"] -# cxt = obj["context"] -# rd = obj["rendered"] -# with st.expander(highlight_prompts_uri(uri), expanded=False, icon="โš™๏ธ"): -# t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"]) -# with t1: -# show_text(rd) -# with t2: -# show_text(tpl, lang="django") -# with t3: -# st.json(cxt) -# if "llm" in m["tag"]: -# obj = m["obj"] -# system = obj.get("system", None) -# user = obj["user"] -# resp = obj["resp"] -# with st.expander(f"**LLM**", expanded=False, icon="๐Ÿค–"): -# t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"]) -# with t1: -# try: -# rdict = json.loads(resp) -# if "code" in rdict: -# code = rdict["code"] -# st.markdown(":red[**Code in response dict:**]") -# st.code(code, language="python", wrap_lines=True, line_numbers=True) -# rdict.pop("code") -# elif "spec" in rdict: -# spec = rdict["spec"] -# st.markdown(":red[**Spec in response dict:**]") -# st.markdown(spec) -# rdict.pop("spec") -# else: -# # show model codes -# showed_keys = [] -# for k, v in rdict.items(): -# if k.startswith("model_") and k.endswith(".py"): -# st.markdown(f":red[**{k}**]") -# st.code(v, language="python", wrap_lines=True, line_numbers=True) -# showed_keys.append(k) -# for k in showed_keys: -# rdict.pop(k) -# st.write(":red[**Other parts (except for the code or spec) in response dict:**]") -# st.json(rdict) -# except: -# st.json(resp) -# with t2: -# show_text(user) -# with t3: -# show_text(system or "No system prompt available") - - -if total_pages: - # ๅˆๅง‹ๅŒ– current_loop - if "current_loop" not in st.session_state: - st.session_state["current_loop"] = 1 - - # Loop ๅฏผ่ˆชๆŒ‰้’ฎ - col1, col2, col3, col4, col5 = st.sidebar.columns([1.2, 1, 2, 1, 1.2]) - - with col1: - if st.button("|<"): # ้ฆ–้กต - st.session_state["current_loop"] = 1 - with col2: - if st.button("<") and st.session_state["current_loop"] > 1: # ไธŠไธ€้กต - st.session_state["current_loop"] -= 1 - with col3: - # ไธ‹ๆ‹‰ๅˆ—่กจๆ˜พ็คบๆ‰€ๆœ‰ Loop - st.session_state["current_loop"] = st.selectbox( - "้€‰ๆ‹ฉ Loop", - options=list(range(1, total_loops + 1)), - index=st.session_state["current_loop"] - 1, # ้ป˜่ฎค้€‰ไธญๅฝ“ๅ‰ Loop - label_visibility="collapsed", # ้š่—ๆ ‡็ญพ - ) - with col4: - if st.button("\>") and st.session_state["current_loop"] < total_loops: # ไธ‹ไธ€้กต - st.session_state["current_loop"] += 1 - with col5: - if st.button("\>|"): # ๆœ€ๅŽไธ€้กต - st.session_state["current_loop"] = total_loops - - # ่Žทๅ–ๅฝ“ๅ‰ Loop - current_loop = st.session_state["current_loop"] - - # ๆธฒๆŸ“ๅฝ“ๅ‰ Loop ๆ•ฐๆฎ - loop_id = sorted_loop_ids[current_loop - 1] - progress_text = st.empty() - progress_text.text(f"ๆญฃๅœจๅค„็† Loop {loop_id}...") - progress_bar.progress(current_loop / total_loops, text=f"Loop :green[**{current_loop}**] / {total_loops}") - - # ๆธฒๆŸ“ Loop Header - loop_anchor = f"Loop_{loop_id}" - if loop_anchor not in tlist: - tlist.append(loop_anchor) - st.header(loop_anchor, anchor=loop_anchor, divider="blue") - - # ๆธฒๆŸ“ๅฝ“ๅ‰ Loop ็š„ๆ‰€ๆœ‰ๆ•ฐๆฎ - loop_data = loop_groups[loop_id] - for d in loop_data: - tag = d["tag"] - obj = d["obj"] - _, func_name = extract_loopid_func_name(tag) - evo_id = extract_evoid(tag) - - func_anchor = f"loop_{loop_id}.{func_name}" - if func_anchor not in tlist: - tlist.append(func_anchor) - st.header(f"in *{func_name}*", anchor=func_anchor, divider="green") - - evo_anchor = f"loop_{loop_id}.evo_step_{evo_id}" - if evo_id and evo_anchor not in tlist: - tlist.append(evo_anchor) - st.subheader(f"evo_step_{evo_id}", anchor=evo_anchor, divider="orange") - - # ๆ นๆฎ tag ๆธฒๆŸ“ๅ†…ๅฎน - if "debug_exp_gen" in tag: - with st.expander( - f"Exp in :violet[**{obj.experiment_workspace.workspace_path}**]", expanded=False, icon="๐Ÿงฉ" - ): - st.write(obj) - elif "debug_tpl" in tag: - uri = obj["uri"] - tpl = obj["template"] - cxt = obj["context"] - rd = obj["rendered"] - with st.expander(highlight_prompts_uri(uri), expanded=False, icon="โš™๏ธ"): - t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"]) - with t1: - show_text(rd) - with t2: - show_text(tpl, lang="django") - with t3: - st.json(cxt) - elif "debug_llm" in tag: - system = obj.get("system", None) - user = obj["user"] - resp = obj["resp"] - with st.expander(f"**LLM**", expanded=False, icon="๐Ÿค–"): - t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"]) - with t1: - try: - rdict = json.loads(resp) - if "code" in rdict: - code = rdict["code"] - st.markdown(":red[**Code in response dict:**]") - st.code(code, language="python", wrap_lines=True, line_numbers=True) - rdict.pop("code") - elif "spec" in rdict: - spec = rdict["spec"] - st.markdown(":red[**Spec in response dict:**]") - st.markdown(spec) - rdict.pop("spec") - else: - # show model codes - showed_keys = [] - for k, v in rdict.items(): - if k.startswith("model_") and k.endswith(".py"): - st.markdown(f":red[**{k}**]") - st.code(v, language="python", wrap_lines=True, line_numbers=True) - showed_keys.append(k) - for k in showed_keys: - rdict.pop(k) - st.write(":red[**Other parts (except for the code or spec) in response dict:**]") - st.json(rdict) - except: - st.json(resp) - with t2: - show_text(user) - with t3: - show_text(system or "No system prompt available") - - progress_text.text("ๅฝ“ๅ‰ Loop ๆ•ฐๆฎๅค„็†ๅฎŒๆˆ๏ผ") - - # Sidebar TOC - with st.sidebar: - toc = "\n".join([f"- [{t}](#{t})" if t.startswith("L") else f" - [{t.split('.')[1]}](#{t})" for t in tlist]) - st.markdown(toc, unsafe_allow_html=True) diff --git a/rdagent/log/ui/qlib_report_figure.py b/rdagent/log/ui/qlib_report_figure.py deleted file mode 100644 index cd4f9418..00000000 --- a/rdagent/log/ui/qlib_report_figure.py +++ /dev/null @@ -1,445 +0,0 @@ -import importlib -import math - -import pandas as pd -import plotly.graph_objs as go -from plotly.subplots import make_subplots - - -class BaseGraph: - _name = None - - def __init__( - self, df: pd.DataFrame = None, layout: dict = None, graph_kwargs: dict = None, name_dict: dict = None, **kwargs - ): - """ - - :param df: - :param layout: - :param graph_kwargs: - :param name_dict: - :param kwargs: - layout: dict - go.Layout parameters - graph_kwargs: dict - Graph parameters, eg: go.Bar(**graph_kwargs) - """ - self._df = df - - self._layout = dict() if layout is None else layout - self._graph_kwargs = dict() if graph_kwargs is None else graph_kwargs - self._name_dict = name_dict - - self.data = None - - self._init_parameters(**kwargs) - self._init_data() - - def _init_data(self): - """ - - :return: - """ - if self._df.empty: - raise ValueError("df is empty.") - - self.data = self._get_data() - - def _init_parameters(self, **kwargs): - """ - - :param kwargs - """ - - # Instantiate graphics parameters - self._graph_type = self._name.lower().capitalize() - - # Displayed column name - if self._name_dict is None: - self._name_dict = {_item: _item for _item in self._df.columns} - - @staticmethod - def get_instance_with_graph_parameters(graph_type: str = None, **kwargs): - """ - - :param graph_type: - :param kwargs: - :return: - """ - try: - _graph_module = importlib.import_module("plotly.graph_objs") - _graph_class = getattr(_graph_module, graph_type) - except AttributeError: - _graph_module = importlib.import_module("qlib.contrib.report.graph") - _graph_class = getattr(_graph_module, graph_type) - return _graph_class(**kwargs) - - def _get_layout(self) -> go.Layout: - """ - - :return: - """ - return go.Layout(**self._layout) - - def _get_data(self) -> list: - """ - - :return: - """ - - _data = [ - self.get_instance_with_graph_parameters( - graph_type=self._graph_type, x=self._df.index, y=self._df[_col], name=_name, **self._graph_kwargs - ) - for _col, _name in self._name_dict.items() - ] - return _data - - @property - def figure(self) -> go.Figure: - """ - - :return: - """ - _figure = go.Figure(data=self.data, layout=self._get_layout()) - # NOTE: Use the default theme from plotly version 3.x, template=None - _figure["layout"].update(template=None) - return _figure - - -class SubplotsGraph: - """Create subplots same as df.plot(subplots=True) - - Simple package for `plotly.tools.subplots` - """ - - def __init__( - self, - df: pd.DataFrame = None, - kind_map: dict = None, - layout: dict = None, - sub_graph_layout: dict = None, - sub_graph_data: list = None, - subplots_kwargs: dict = None, - **kwargs, - ): - """ - - :param df: pd.DataFrame - - :param kind_map: dict, subplots graph kind and kwargs - eg: dict(kind='Scatter', kwargs=dict()) - - :param layout: `go.Layout` parameters - - :param sub_graph_layout: Layout of each graphic, similar to 'layout' - - :param sub_graph_data: Instantiation parameters for each sub-graphic - eg: [(column_name, instance_parameters), ] - - column_name: str or go.Figure - - Instance_parameters: - - - row: int, the row where the graph is located - - - col: int, the col where the graph is located - - - name: str, show name, default column_name in 'df' - - - kind: str, graph kind, default `kind` param, eg: bar, scatter, ... - - - graph_kwargs: dict, graph kwargs, default {}, used in `go.Bar(**graph_kwargs)` - - :param subplots_kwargs: `plotly.tools.make_subplots` original parameters - - - shared_xaxes: bool, default False - - - shared_yaxes: bool, default False - - - vertical_spacing: float, default 0.3 / rows - - - subplot_titles: list, default [] - If `sub_graph_data` is None, will generate 'subplot_titles' according to `df.columns`, - this field will be discarded - - - - specs: list, see `make_subplots` docs - - - rows: int, Number of rows in the subplot grid, default 1 - If `sub_graph_data` is None, will generate 'rows' according to `df`, this field will be discarded - - - cols: int, Number of cols in the subplot grid, default 1 - If `sub_graph_data` is None, will generate 'cols' according to `df`, this field will be discarded - - - :param kwargs: - - """ - - self._df = df - self._layout = layout - self._sub_graph_layout = sub_graph_layout - - self._kind_map = kind_map - if self._kind_map is None: - self._kind_map = dict(kind="Scatter", kwargs=dict()) - - self._subplots_kwargs = subplots_kwargs - if self._subplots_kwargs is None: - self._init_subplots_kwargs() - - self.__cols = self._subplots_kwargs.get("cols", 2) # pylint: disable=W0238 - self.__rows = self._subplots_kwargs.get( # pylint: disable=W0238 - "rows", math.ceil(len(self._df.columns) / self.__cols) - ) - - self._sub_graph_data = sub_graph_data - if self._sub_graph_data is None: - self._init_sub_graph_data() - - self._init_figure() - - def _init_sub_graph_data(self): - """ - - :return: - """ - self._sub_graph_data = [] - self._subplot_titles = [] - - for i, column_name in enumerate(self._df.columns): - row = math.ceil((i + 1) / self.__cols) - _temp = (i + 1) % self.__cols - col = _temp if _temp else self.__cols - res_name = column_name.replace("_", " ") - _temp_row_data = ( - column_name, - dict( - row=row, - col=col, - name=res_name, - kind=self._kind_map["kind"], - graph_kwargs=self._kind_map["kwargs"], - ), - ) - self._sub_graph_data.append(_temp_row_data) - self._subplot_titles.append(res_name) - - def _init_subplots_kwargs(self): - """ - - :return: - """ - # Default cols, rows - _cols = 2 - _rows = math.ceil(len(self._df.columns) / 2) - self._subplots_kwargs = dict() - self._subplots_kwargs["rows"] = _rows - self._subplots_kwargs["cols"] = _cols - self._subplots_kwargs["shared_xaxes"] = False - self._subplots_kwargs["shared_yaxes"] = False - self._subplots_kwargs["vertical_spacing"] = 0.3 / _rows - self._subplots_kwargs["print_grid"] = False - self._subplots_kwargs["subplot_titles"] = self._df.columns.tolist() - - def _init_figure(self): - """ - - :return: - """ - self._figure = make_subplots(**self._subplots_kwargs) - - for column_name, column_map in self._sub_graph_data: - if isinstance(column_name, go.Figure): - _graph_obj = column_name - elif isinstance(column_name, str): - temp_name = column_map.get("name", column_name.replace("_", " ")) - kind = column_map.get("kind", self._kind_map.get("kind", "Scatter")) - _graph_kwargs = column_map.get("graph_kwargs", self._kind_map.get("kwargs", {})) - _graph_obj = BaseGraph.get_instance_with_graph_parameters( - kind, - **dict( - x=self._df.index, - y=self._df[column_name], - name=temp_name, - **_graph_kwargs, - ), - ) - else: - raise TypeError() - - row = column_map["row"] - col = column_map["col"] - - self._figure.add_trace(_graph_obj, row=row, col=col) - - if self._sub_graph_layout is not None: - for k, v in self._sub_graph_layout.items(): - self._figure["layout"][k].update(v) - - # NOTE: Use the default theme from plotly version 3.x: template=None - self._figure["layout"].update(template=None) - self._figure["layout"].update(self._layout) - - @property - def figure(self): - return self._figure - - -def _calculate_maximum(df: pd.DataFrame, is_ex: bool = False): - """ - - :param df: - :param is_ex: - :return: - """ - if is_ex: - end_date = df["cum_ex_return_wo_cost_mdd"].idxmin() - start_date = df.loc[df.index <= end_date]["cum_ex_return_wo_cost"].idxmax() - else: - end_date = df["return_wo_mdd"].idxmin() - start_date = df.loc[df.index <= end_date]["cum_return_wo_cost"].idxmax() - return start_date, end_date - - -def _calculate_mdd(series): - """ - Calculate mdd - - :param series: - :return: - """ - return series - series.cummax() - - -def _calculate_report_data(raw_df: pd.DataFrame) -> pd.DataFrame: - """ - - :param df: - :return: - """ - df = raw_df.copy(deep=True) - index_names = df.index.names - df.index = df.index.strftime("%Y-%m-%d") - - report_df = pd.DataFrame() - - report_df["cum_bench"] = df["bench"].cumsum() - report_df["cum_return_wo_cost"] = df["return"].cumsum() - report_df["cum_return_w_cost"] = (df["return"] - df["cost"]).cumsum() - # report_df['cum_return'] - report_df['cum_return'].cummax() - report_df["return_wo_mdd"] = _calculate_mdd(report_df["cum_return_wo_cost"]) - report_df["return_w_cost_mdd"] = _calculate_mdd((df["return"] - df["cost"]).cumsum()) - - report_df["cum_ex_return_wo_cost"] = (df["return"] - df["bench"]).cumsum() - report_df["cum_ex_return_w_cost"] = (df["return"] - df["bench"] - df["cost"]).cumsum() - report_df["cum_ex_return_wo_cost_mdd"] = _calculate_mdd((df["return"] - df["bench"]).cumsum()) - report_df["cum_ex_return_w_cost_mdd"] = _calculate_mdd((df["return"] - df["cost"] - df["bench"]).cumsum()) - # return_wo_mdd , return_w_cost_mdd, cum_ex_return_wo_cost_mdd, cum_ex_return_w - - report_df["turnover"] = df["turnover"] - report_df.sort_index(ascending=True, inplace=True) - - report_df.index.names = index_names - return report_df - - -def report_figure(df: pd.DataFrame) -> list | tuple: - """ - - :param df: - :return: - """ - - # Get data - report_df = _calculate_report_data(df) - - # Maximum Drawdown - max_start_date, max_end_date = _calculate_maximum(report_df) - ex_max_start_date, ex_max_end_date = _calculate_maximum(report_df, True) - - index_name = report_df.index.name - _temp_df = report_df.reset_index() - _temp_df.loc[-1] = 0 - _temp_df = _temp_df.shift(1) - _temp_df.loc[0, index_name] = "T0" - _temp_df.set_index(index_name, inplace=True) - _temp_df.iloc[0] = 0 - report_df = _temp_df - - # Create figure - _default_kind_map = dict(kind="Scatter", kwargs={"mode": "lines+markers"}) - _temp_fill_args = {"fill": "tozeroy", "mode": "lines+markers"} - _column_row_col_dict = [ - ("cum_bench", dict(row=1, col=1)), - ("cum_return_wo_cost", dict(row=1, col=1)), - ("cum_return_w_cost", dict(row=1, col=1)), - ("return_wo_mdd", dict(row=2, col=1, graph_kwargs=_temp_fill_args)), - ("return_w_cost_mdd", dict(row=3, col=1, graph_kwargs=_temp_fill_args)), - ("cum_ex_return_wo_cost", dict(row=4, col=1)), - ("cum_ex_return_w_cost", dict(row=4, col=1)), - ("turnover", dict(row=5, col=1)), - ("cum_ex_return_w_cost_mdd", dict(row=6, col=1, graph_kwargs=_temp_fill_args)), - ("cum_ex_return_wo_cost_mdd", dict(row=7, col=1, graph_kwargs=_temp_fill_args)), - ] - - _subplot_layout = dict() - for i in range(1, 8): - # yaxis - _subplot_layout.update({"yaxis{}".format(i): dict(zeroline=True, showline=True, showticklabels=True)}) - _show_line = i == 7 - _subplot_layout.update({"xaxis{}".format(i): dict(showline=_show_line, type="category", tickangle=45)}) - - _layout_style = dict( - height=1200, - title=" ", - shapes=[ - { - "type": "rect", - "xref": "x", - "yref": "paper", - "x0": max_start_date, - "y0": 0.55, - "x1": max_end_date, - "y1": 1, - "fillcolor": "#d3d3d3", - "opacity": 0.3, - "line": { - "width": 0, - }, - }, - { - "type": "rect", - "xref": "x", - "yref": "paper", - "x0": ex_max_start_date, - "y0": 0, - "x1": ex_max_end_date, - "y1": 0.55, - "fillcolor": "#d3d3d3", - "opacity": 0.3, - "line": { - "width": 0, - }, - }, - ], - ) - - _subplot_kwargs = dict( - shared_xaxes=True, - vertical_spacing=0.01, - rows=7, - cols=1, - row_width=[1, 1, 1, 3, 1, 1, 3], - print_grid=False, - ) - figure = SubplotsGraph( - df=report_df, - layout=_layout_style, - sub_graph_data=_column_row_col_dict, - subplots_kwargs=_subplot_kwargs, - kind_map=_default_kind_map, - sub_graph_layout=_subplot_layout, - ).figure - return figure diff --git a/rdagent/log/ui/st_fixed_container.py b/rdagent/log/ui/st_fixed_container.py deleted file mode 100644 index 262f0388..00000000 --- a/rdagent/log/ui/st_fixed_container.py +++ /dev/null @@ -1,126 +0,0 @@ -from typing import Literal - -import streamlit as st -from streamlit.components.v1 import html - -FIXED_CONTAINER_CSS = """ -:root {{ - --background-color: #ffffff; /* Default background color */ -}} -div[data-testid="stVerticalBlockBorderWrapper"]:has(div.fixed-container-{id}):not(:has(div.not-fixed-container)) {{ - position: {mode}; - width: inherit; - background-color: inherit; - {position}: {margin}; - z-index: 999; -}} -div[data-testid="stVerticalBlockBorderWrapper"]:has(div.fixed-container-{id}):not(:has(div.not-fixed-container)) div[data-testid="stVerticalBlock"]:has(div.fixed-container-{id}):not(:has(div.not-fixed-container)) > div[data-testid="stVerticalBlockBorderWrapper"] {{ - background-color: transparent; - width: 100%; -}} -div[data-testid="stVerticalBlockBorderWrapper"]:has(div.fixed-container-{id}):not(:has(div.not-fixed-container)) div[data-testid="stVerticalBlock"]:has(div.fixed-container-{id}):not(:has(div.not-fixed-container)) > div[data-testid="stVerticalBlockBorderWrapper"] div[data-testid="stVerticalBlockBorderWrapper"] {{ - background-color: var(--background-color); -}} -div[data-testid="stVerticalBlockBorderWrapper"]:has(div.fixed-container-{id}):not(:has(div.not-fixed-container)) div[data-testid="stVerticalBlock"]:has(div.fixed-container-{id}):not(:has(div.not-fixed-container)) > div[data-testid="element-container"] {{ - display: none; -}} -div[data-testid="stVerticalBlockBorderWrapper"]:has(div.not-fixed-container):not(:has(div[class^='fixed-container-'])) {{ - display: none; -}} -""".strip() - -FIXED_CONTAINER_JS = """ -const root = parent.document.querySelector('.stApp'); -let lastBackgroundColor = null; -function updateContainerBackground(currentBackground) { - parent.document.documentElement.style.setProperty('--background-color', currentBackground); - ; -} -function checkForBackgroundColorChange() { - const style = window.getComputedStyle(root); - const currentBackgroundColor = style.backgroundColor; - if (currentBackgroundColor !== lastBackgroundColor) { - lastBackgroundColor = currentBackgroundColor; // Update the last known value - updateContainerBackground(lastBackgroundColor); - } -} -const observerCallback = (mutationsList, observer) => { - for(let mutation of mutationsList) { - if (mutation.type === 'attributes' && (mutation.attributeName === 'class' || mutation.attributeName === 'style')) { - checkForBackgroundColorChange(); - } - } -}; -const main = () => { - checkForBackgroundColorChange(); - const observer = new MutationObserver(observerCallback); - observer.observe(root, { attributes: true, childList: false, subtree: false }); -} -// main(); -document.addEventListener("DOMContentLoaded", main); -""".strip() - - -MARGINS = { - "top": "2.875rem", - "bottom": "0", -} - - -counter = 0 - - -def st_fixed_container( - *, - height: int | None = None, - border: bool | None = None, - mode: Literal["fixed", "sticky"] = "fixed", - position: Literal["top", "bottom"] = "top", - margin: str | None = None, - transparent: bool = False, -): - if margin is None: - margin = MARGINS[position] - global counter - - fixed_container = st.container() - non_fixed_container = st.container() - css = FIXED_CONTAINER_CSS.format( - mode=mode, - position=position, - margin=margin, - id=counter, - ) - with fixed_container: - html(f"", scrolling=False, height=0) - st.markdown(f"", unsafe_allow_html=True) - st.markdown( - f"
", - unsafe_allow_html=True, - ) - with non_fixed_container: - st.markdown( - f"
", - unsafe_allow_html=True, - ) - counter += 1 - - parent_container = fixed_container if transparent else fixed_container.container() - return parent_container.container(height=height, border=border) - - -if __name__ == "__main__": - for i in range(30): - st.write(f"Line {i}") - - # with st_fixed_container(mode="sticky", position="top", border=True): - # with st_fixed_container(mode="sticky", position="bottom", border=True): - # with st_fixed_container(mode="fixed", position="top", border=True): - with st_fixed_container(mode="fixed", position="bottom", border=True): - st.write("This is a fixed container.") - st.write("This is a fixed container.") - st.write("This is a fixed container.") - - st.container(border=True).write("This is a regular container.") - for i in range(30): - st.write(f"Line {i}") diff --git a/rdagent/log/ui/storage.py b/rdagent/log/ui/storage.py deleted file mode 100644 index 96366eae..00000000 --- a/rdagent/log/ui/storage.py +++ /dev/null @@ -1,345 +0,0 @@ -from datetime import datetime -from pathlib import Path -from typing import Any, Generator - -import requests - -from rdagent.log.base import Message, Storage -from rdagent.log.utils import extract_evoid, extract_loopid_func_name, gen_datetime - -from .conf import UI_SETTING - - -class WebStorage(Storage): - """ - The storage for web app. - It is used to provide the data for the web app. - """ - - def __init__(self, port: int, path: str) -> None: - """ - Initializes the storage object with the specified port and identifier. - Args: - port (int): The port number to use for the storage service. - path (str): The unique identifier for local storage, the log path. - """ - self.url = f"http://localhost:{port}" - self.path = path - self.msgs = [] - - def __str__(self): - return f"WebStorage({self.url})" - - def log(self, obj: object, tag: str, timestamp: datetime | None = None, **kwargs: Any) -> str | Path: - timestamp = gen_datetime(timestamp) - if "pdf_image" in tag or "load_pdf_screenshot" in tag: - Path(f"{UI_SETTING.static_path}/pdf_images").mkdir(parents=True, exist_ok=True) - obj.save(f"{UI_SETTING.static_path}/pdf_images/{timestamp.isoformat()}.jpg") - - try: - data = self._obj_to_json(obj=obj, tag=tag, id=str(self.path), timestamp=timestamp.isoformat()) - if not data: - return "Normal log, skipped" - if isinstance(data, list): - for d in data: - self.msgs.append(d) - else: - self.msgs.append(data) - headers = {"Content-Type": "application/json"} - resp = requests.post(f"{self.url}/receive", json=data, headers=headers, timeout=1) - return f"{resp.status_code} {resp.text}" - except (requests.ConnectionError, requests.Timeout) as e: - print(f"Failed to connect to the web storage server at {self.url}: {e}") - - def truncate(self, time: datetime) -> None: - self.msgs = [m for m in self.msgs if datetime.fromisoformat(m["msg"]["timestamp"]) <= time] - - def iter_msg(self, **kwargs: Any) -> Generator[Message, None, None]: - for msg in self.msgs: - yield Message( - tag=msg["msg"]["tag"], - level="INFO", - timestamp=datetime.fromisoformat(msg["msg"]["timestamp"]), - content=msg, - ) - - def _obj_to_json( - self, - obj: object, - tag: str, - id: str, - timestamp: str, - ) -> list[dict] | dict: - li, fn = extract_loopid_func_name(tag) - ei = extract_evoid(tag) - data = {} - if "hypothesis generation" in tag: - from rdagent.core.proposal import Hypothesis - - h: Hypothesis = obj - data = { - "id": id, - "msg": { - "tag": "research.hypothesis", - "timestamp": timestamp, - "loop_id": li, - "content": { - "hypothesis": h.hypothesis, - "reason": h.reason, - "concise_reason": h.concise_reason, - "concise_justification": h.concise_justification, - "concise_observation": h.concise_observation, - "concise_knowledge": h.concise_knowledge, - }, - }, - } - elif "pdf_image" in tag or "load_pdf_screenshot" in tag: - # obj.save(f"{app.static_folder}/{timestamp}.jpg") - data = { - "id": id, - "msg": { - "tag": "research.pdf_image", - "timestamp": timestamp, - "loop_id": li, - "content": {"image": f"pdf_images/{timestamp}.jpg"}, - }, - } - elif "experiment generation" in tag or "load_experiment" in tag: - from rdagent.components.coder.factor_coder.factor import FactorTask - from rdagent.components.coder.model_coder.model import ModelTask - - if "load_experiment" in tag: - tasks: list[FactorTask | ModelTask] = obj.sub_tasks - else: - tasks: list[FactorTask | ModelTask] = obj - if isinstance(tasks[0], FactorTask): - data = { - "id": id, - "msg": { - "tag": "research.tasks", - "timestamp": timestamp, - "loop_id": li, - "content": [ - { - "name": t.factor_name, - "description": t.factor_description, - "formulation": t.factor_formulation, - "variables": t.variables, - } - for t in tasks - ], - }, - } - elif isinstance(tasks[0], ModelTask): - data = { - "id": id, - "msg": { - "tag": "research.tasks", - "timestamp": timestamp, - "loop_id": li, - "content": [ - { - "name": t.name, - "description": t.description, - "model_type": t.model_type, - "formulation": t.formulation, - "variables": t.variables, - } - for t in tasks - ], - }, - } - elif "direct_exp_gen" in tag: - from rdagent.scenarios.data_science.experiment.experiment import ( - DSExperiment, - ) - - if isinstance(obj, DSExperiment): - from rdagent.scenarios.data_science.proposal.exp_gen.base import ( - DSHypothesis, - ) - - h: DSHypothesis = obj.hypothesis - tasks = [t[0] for t in obj.pending_tasks_list] - t = tasks[0] - t.name = type(t).__name__ # TODO: PipelinTask have "COMPONENT" in name, fix this when creating task. - data = [ - { - "id": id, - "msg": { - "tag": "research.hypothesis", - "old_tag": tag, - "timestamp": timestamp, - "loop_id": li, - "content": { - "name_map": { - "hypothesis": "RD-Agent proposes the hypothesisโฌ‡๏ธ", - "concise_justification": "because the reasonโฌ‡๏ธ", - "concise_observation": "based on the observationโฌ‡๏ธ", - "concise_knowledge": "Knowledgeโฌ‡๏ธ gained after practice", - "no_hypothesis": f"No hypothesis available. Trying to construct the first runnable {h.component} component.", - }, - "hypothesis": h.hypothesis, - "reason": h.reason, - "component": h.component, - "concise_reason": h.concise_reason, - "concise_justification": h.concise_justification, - "concise_observation": h.concise_observation, - "concise_knowledge": h.concise_knowledge, - }, - }, - }, - { - "id": id, - "msg": { - "tag": "research.tasks", - "old_tag": tag, - "timestamp": timestamp, - "loop_id": li, - "content": [ - ( - { - "name": t.name, - "description": t.description, - } - if not hasattr(t, "architecture") - else { - "name": t.name, - "description": t.description, - "model_type": t.model_type, - "architecture": t.architecture, - "hyperparameters": t.hyperparameters, - } - ) - ], - }, - }, - ] - elif f"evo_loop_{ei}.evolving code" in tag and "running" not in tag: - from rdagent.core.experiment import FBWorkspace - - ws: list[FBWorkspace] = [i for i in obj] - data = { - "id": id, - "msg": { - "tag": "evolving.codes", - "timestamp": timestamp, - "loop_id": li, - "evo_id": ei, - "content": [ - { - "evo_id": ei, - "target_task_name": ( - w.target_task.name if w.target_task else "PipelineTask" - ), # TODO: save this when proposal - "workspace": w.file_dict, - } - for w in ws - ], - }, - } - elif f"evo_loop_{ei}.evolving feedback" in tag and "running" not in tag: - from rdagent.components.coder.CoSTEER.evaluators import ( - CoSTEERSingleFeedback, - ) - - fl: list[CoSTEERSingleFeedback] = [i for i in obj] - data = { - "id": id, - "msg": { - "tag": "evolving.feedbacks", - "timestamp": timestamp, - "loop_id": li, - "evo_id": ei, - "content": [ - { - "evo_id": ei, - "final_decision": f.final_decision, - # "final_feedback": f.final_feedback, - "execution": f.execution, - "code": f.code, - "return_checking": f.return_checking, - } - for f in fl - ], - }, - } - elif "scenario" in tag: - data = { - "id": id, - "msg": { - "tag": "feedback.config", - "timestamp": timestamp, - "loop_id": li, - "content": {"config": obj.experiment_setting}, - }, - } - - elif "Quantitative Backtesting Chart" in tag: - import plotly - - from rdagent.log.ui.qlib_report_figure import report_figure - - data = { - "id": id, - "msg": { - "tag": "feedback.return_chart", - "timestamp": timestamp, - "loop_id": li, - "content": {"chart_html": plotly.io.to_html(report_figure(obj))}, - }, - } - elif "running" in tag: - from rdagent.core.experiment import Experiment - - if isinstance(obj, Experiment): - try: - result = obj.result - except AttributeError: # compatibility with old versions - result = obj.__dict__["result"] - if result is not None: - result_str = result.to_json() - data = { - "id": id, - "msg": { - "tag": "feedback.metric", - "old_tag": tag, - "timestamp": timestamp, - "loop_id": li, - "content": { - "result": result_str, - }, - }, - } - elif "feedback" in tag: - from rdagent.core.proposal import ExperimentFeedback, HypothesisFeedback - - if isinstance(obj, ExperimentFeedback): - ef: ExperimentFeedback = obj - content = ( - { - "observations": str(ef.observations), - "hypothesis_evaluation": ef.hypothesis_evaluation, - "new_hypothesis": ef.new_hypothesis, - "decision": ef.decision, - "reason": ef.reason, - "exception": ef.exception, - } - if isinstance(ef, HypothesisFeedback) - else { - "decision": ef.decision, - "reason": ef.reason, - "exception": ef.exception, - } - ) - data = { - "id": id, - "msg": { - "tag": "feedback.hypothesis_feedback", - "timestamp": timestamp, - "loop_id": li, - "content": content, - }, - } - - return data diff --git a/rdagent/log/ui/utils.py b/rdagent/log/ui/utils.py deleted file mode 100644 index 7696386c..00000000 --- a/rdagent/log/ui/utils.py +++ /dev/null @@ -1,1128 +0,0 @@ -import math -import pickle -import re -from collections import defaultdict, deque -from datetime import datetime, timedelta -from pathlib import Path -from typing import Literal - -import matplotlib.pyplot as plt -import networkx as nx -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go -import typer -from matplotlib import pyplot as plt - -from rdagent.app.data_science.loop import DataScienceRDLoop -from rdagent.core.proposal import Trace -from rdagent.core.utils import cache_with_pickle -from rdagent.log.storage import FileStorage -from rdagent.log.ui.conf import UI_SETTING -from rdagent.log.utils import extract_json, extract_loopid_func_name -from rdagent.oai.llm_utils import md5_hash -from rdagent.scenarios.data_science.experiment.experiment import DSExperiment -from rdagent.scenarios.data_science.proposal.exp_gen.select.submit import ( - BestValidSelector, -) -from rdagent.scenarios.kaggle.kaggle_crawler import get_metric_direction - -LITE = [ - "aerial-cactus-identification", - "aptos2019-blindness-detection", - "denoising-dirty-documents", - "detecting-insults-in-social-commentary", - "dog-breed-identification", - "dogs-vs-cats-redux-kernels-edition", - "histopathologic-cancer-detection", - "jigsaw-toxic-comment-classification-challenge", - "leaf-classification", - "mlsp-2013-birds", - "new-york-city-taxi-fare-prediction", - "nomad2018-predict-transparent-conductors", - "plant-pathology-2020-fgvc7", - "random-acts-of-pizza", - "ranzcr-clip-catheter-line-classification", - "siim-isic-melanoma-classification", - "spooky-author-identification", - "tabular-playground-series-dec-2021", - "tabular-playground-series-may-2022", - "text-normalization-challenge-english-language", - "text-normalization-challenge-russian-language", - "the-icml-2013-whale-challenge-right-whale-redux", -] - -HIGH = [ - "3d-object-detection-for-autonomous-vehicles", - "bms-molecular-translation", - "google-research-identify-contrails-reduce-global-warming", - "hms-harmful-brain-activity-classification", - "iwildcam-2019-fgvc6", - "nfl-player-contact-detection", - "predict-volcanic-eruptions-ingv-oe", - "rsna-2022-cervical-spine-fracture-detection", - "rsna-breast-cancer-detection", - "rsna-miccai-brain-tumor-radiogenomic-classification", - "siim-covid19-detection", - "smartphone-decimeter-2022", - "stanford-covid-vaccine", - "vesuvius-challenge-ink-detection", - "vinbigdata-chest-xray-abnormalities-detection", -] - -MEDIUM = [ - "AI4Code", - "alaska2-image-steganalysis", - "billion-word-imputation", - "cassava-leaf-disease-classification", - "cdiscount-image-classification-challenge", - "chaii-hindi-and-tamil-question-answering", - "champs-scalar-coupling", - "facebook-recruiting-iii-keyword-extraction", - "freesound-audio-tagging-2019", - "google-quest-challenge", - "h-and-m-personalized-fashion-recommendations", - "herbarium-2020-fgvc7", - "herbarium-2021-fgvc8", - "herbarium-2022-fgvc9", - "hotel-id-2021-fgvc8", - "hubmap-kidney-segmentation", - "icecube-neutrinos-in-deep-ice", - "imet-2020-fgvc7", - "inaturalist-2019-fgvc6", - "iwildcam-2020-fgvc7", - "jigsaw-unintended-bias-in-toxicity-classification", - "kuzushiji-recognition", - "learning-agency-lab-automated-essay-scoring-2", - "lmsys-chatbot-arena", - "multi-modal-gesture-recognition", - "osic-pulmonary-fibrosis-progression", - "petfinder-pawpularity-score", - "plant-pathology-2021-fgvc8", - "seti-breakthrough-listen", - "statoil-iceberg-classifier-challenge", - "tensorflow-speech-recognition-challenge", - "tensorflow2-question-answering", - "tgs-salt-identification-challenge", - "tweet-sentiment-extraction", - "us-patent-phrase-to-phrase-matching", - "uw-madison-gi-tract-image-segmentation", - "ventilator-pressure-prediction", - "whale-categorization-playground", -] - -ALL = HIGH + MEDIUM + LITE - - -def get_script_time(stdout_p: Path): - with stdout_p.open("r") as f: - first_line = next(f).strip() - last_line = deque(f, maxlen=1).pop().strip() - - # Extract timestamps from the lines - first_time_match = re.search(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\+\d{2}:\d{2})", first_line) - last_time_match = re.search(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\+\d{2}:\d{2})", last_line) - - if first_time_match and last_time_match: - first_time = datetime.fromisoformat(first_time_match.group(1)) - last_time = datetime.fromisoformat(last_time_match.group(1)) - return pd.Timedelta(last_time - first_time) - - return None - - -def _log_path_hash_func(log_path: Path) -> str: - hash_str = str(log_path) + str(log_path.stat().st_mtime) - session_p = log_path / "__session__" - if session_p.exists(): - for ld in session_p.iterdir(): - if ld.is_dir(): - hash_str += str(ld.name) + str(ld.stat().st_mtime) - else: - hash_str += "no session now" - return md5_hash(hash_str) - - -def map_stat(sota_mle_score: dict | None) -> str: - sota_exp_stat = None - if sota_mle_score: # sota exp's grade output - if sota_mle_score["gold_medal"]: - sota_exp_stat = "gold" - elif sota_mle_score["silver_medal"]: - sota_exp_stat = "silver" - elif sota_mle_score["bronze_medal"]: - sota_exp_stat = "bronze" - elif sota_mle_score["above_median"]: - sota_exp_stat = "above_median" - elif sota_mle_score["valid_submission"]: - sota_exp_stat = "valid_submission" - elif sota_mle_score["submission_exists"]: - sota_exp_stat = "made_submission" - return sota_exp_stat - - -def get_best_report(log_path: Path) -> dict | None: - log_storage = FileStorage(log_path) - mle_reports = [extract_json(i.content) for i in log_storage.iter_msg(pattern="**/running/mle_score/*/*.pkl")] - mle_reports = [report for report in mle_reports if report is not None and not pd.isna(report["score"])] - if mle_reports: - lower_better = mle_reports[0]["is_lower_better"] - if lower_better: - mle_reports.sort(key=lambda report: report["score"]) - else: - mle_reports.sort(key=lambda report: report["score"], reverse=True) - return mle_reports[0] - return None - - -if UI_SETTING.enable_cache: - get_best_report = cache_with_pickle(_log_path_hash_func, force=True)(get_best_report) - - -def _get_sota_exp_stat_hash_func(log_path: Path, selector: Literal["auto", "best_valid"] = "auto") -> str: - return _log_path_hash_func(log_path) + selector - - -def get_sota_exp_stat( - log_path: Path, selector: Literal["auto", "best_valid"] = "auto" -) -> tuple[DSExperiment | None, int | None, dict | None, str | None]: - """ - Get the SOTA experiment and its statistics from the log path. - - Parameters - ---------- - log_path : Path - Path to the experiment log directory. - selector : Literal["auto", "best_valid"], default "auto" - If "auto", returns sota_exp_to_submit; if "best_valid", returns sota selected by best valid score. - - Returns - ------- - tuple[DSExperiment | None, int | None, dict | None, str | None] - A tuple containing: - - sota_exp : DSExperiment or None - The SOTA experiment object or None if not found. - - sota_loop_id : int or None - The loop ID of the SOTA experiment or None if not found. - - sota_mle_score : dict or None - The MLE score dictionary of the SOTA experiment or None if not found. - - sota_exp_stat : str or None - The medal status string ("gold", "silver", "bronze", etc.) or None if not found. - """ - log_storage = FileStorage(log_path) - - # get sota exp - sota_exp = None - if selector == "auto": - sota_exp_list = [i.content for i in log_storage.iter_msg(tag="sota_exp_to_submit")] - sota_exp = sota_exp_list[-1] if sota_exp_list else None - elif selector == "best_valid": - trace_list = [i.content for i in log_storage.iter_msg(tag="trace")] - if trace_list: - final_trace = trace_list[-1] - final_trace.scen.metric_direction = get_metric_direction( - final_trace.scen.competition - ) # FIXME: remove this later. - bvs = BestValidSelector() - sota_exp = bvs.get_sota_exp_to_submit(final_trace) - - if sota_exp is None: - return None, None, None, None - - # find sota exp's loop id - sota_loop_id = None - running_exps: list[tuple[DSExperiment, int]] = [ - (i.content, int(re.search(r".*Loop_(\d+).*", str(i.tag))[1])) - for i in log_storage.iter_msg(pattern="**/running/*/*.pkl") - ] - running_exps.sort(key=lambda x: x[1], reverse=True) - for exp, loop_id in running_exps: - if exp.experiment_workspace.all_codes == sota_exp.experiment_workspace.all_codes and "".join( - str(i) for i in exp.hypothesis.__dict__.values() - ) == "".join(str(i) for i in sota_exp.hypothesis.__dict__.values()): - sota_loop_id = loop_id - break - - # get sota exp's mle score - try: - sota_mle_score = extract_json( - [i.content for i in log_storage.iter_msg(tag=f"Loop_{sota_loop_id}.running.mle_score")][0] - ) - except Exception as e: - # sota exp is not tested yet - return sota_exp, sota_loop_id, None, None - - return sota_exp, sota_loop_id, sota_mle_score, map_stat(sota_mle_score) - - -if UI_SETTING.enable_cache: - get_sota_exp_stat = cache_with_pickle(_get_sota_exp_stat_hash_func, force=True)(get_sota_exp_stat) - - -def _get_score_stat_hash_func(log_path: Path, sota_loop_id: int) -> str: - return _log_path_hash_func(log_path) + str(sota_loop_id) - - -def get_score_stat(log_path: Path, sota_loop_id: int) -> tuple[float | None, float | None, bool | None, float | None]: - """ - Get the scores before and after merge period. - - Parameters - ---------- - log_path : Path - Path to the experiment log directory. - sota_loop_id : int - The loop ID of the SOTA experiment to check for merge status. - - Returns - ------- - tuple[float | None, float | None] - A tuple containing: - - valid_improve : bool - True if valid score is improved during merge period. - - test_improve : bool - True if test score is improved during merge period. - - submit_is_merge : bool - True if the sota loop is a merge loop. - - merge_sota_rate : float | None - The merge sota rate. - """ - valid_before_merge = [] - test_before_merge = [] - valid_after_merge = [] - test_after_merge = [] - submit_is_merge = False - is_lower_better = False - valid_improve = False - test_improve = False - total_merge_loops = 0 - log_storage = FileStorage(log_path) - all_trace = list(log_storage.iter_msg(tag="trace")) - if all_trace: - final_trace = all_trace[-1].content - else: - return None, None, None, None - for loop_index, (exp, fb) in enumerate(final_trace.hist): - if hasattr(final_trace, "idx2loop_id"): - loop_id = final_trace.idx2loop_id[loop_index] - else: - loop_id = int(re.search(r"\d+", all_trace[loop_index].tag).group()) - - is_merge = False - direct_exp_gen = log_storage.iter_msg(pattern=f"Loop_{loop_id}/direct_exp_gen/debug_tpl/*/*.pkl") - for tr in direct_exp_gen: - uri = tr.content.get("uri") if isinstance(tr.content, dict) else getattr(tr.content, "uri", None) - if isinstance(uri, str) and "scenarios.data_science.proposal.exp_gen.merge" in uri: - is_merge = True - total_merge_loops += 1 - if sota_loop_id == loop_id: - submit_is_merge = True - break - if not fb.decision: - continue - - try: - mle_score = extract_json( - [i.content for i in log_storage.iter_msg(tag=f"Loop_{loop_id}.running.mle_score")][0] - ) - except Exception: - continue - - if not mle_score: - continue - - is_lower_better = mle_score.get("is_lower_better", False) - valid_score = pd.DataFrame(exp.result).loc["ensemble"].iloc[0] - - if is_merge: - valid_after_merge.append(valid_score) - if mle_score["score"] is not None: - test_after_merge.append(mle_score["score"]) - else: - valid_before_merge.append(valid_score) - if mle_score["score"] is not None: - test_before_merge.append(mle_score["score"]) - - if is_lower_better: - if valid_after_merge: - valid_improve = not valid_before_merge or min(valid_after_merge) < min(valid_before_merge) - if test_after_merge: - test_improve = not test_before_merge or min(test_after_merge) < min(test_before_merge) - else: - if valid_after_merge: - valid_improve = not valid_before_merge or max(valid_after_merge) > max(valid_before_merge) - if test_after_merge: - test_improve = not test_before_merge or max(test_after_merge) > max(test_before_merge) - - merge_sota_rate = 0 if not total_merge_loops else len(test_after_merge) / total_merge_loops - return valid_improve, test_improve, submit_is_merge, merge_sota_rate - - -if UI_SETTING.enable_cache: - get_score_stat = cache_with_pickle(_get_score_stat_hash_func, force=True)(get_score_stat) - - -def load_times_deprecated(log_path: Path): - try: - session_path = log_path / "__session__" - max_li = max(int(p.name) for p in session_path.iterdir() if p.is_dir() and p.name.isdigit()) - max_step = max(int(p.name.split("_")[0]) for p in (session_path / str(max_li)).iterdir() if p.is_file()) - rdloop_obj_p = next((session_path / str(max_li)).glob(f"{max_step}_*")) - - rd_times = DataScienceRDLoop.load(rdloop_obj_p).loop_trace - except Exception as e: - rd_times = {} - return rd_times - - -if UI_SETTING.enable_cache: - load_times_deprecated = cache_with_pickle(_log_path_hash_func, force=True)(load_times_deprecated) - - -def load_times_info(log_path: Path) -> dict[int, dict[str, dict[Literal["start_time", "end_time"], datetime]]]: - """ - Load timing information for each loop and step. - - Returns - ------- - dict[int, dict[str, dict[Literal["start_time", "end_time"], datetime]]] - Dictionary with loop IDs as keys, where each value contains step names - mapping to their start and end times. - - Example: - { - 1: { - "exp_gen": { - "start_time": datetime(2024, 1, 1, 10, 0, 0), - "end_time": datetime(2024, 1, 1, 10, 15, 30) - }, - "coding": { - "start_time": datetime(2024, 1, 1, 10, 15, 30), - "end_time": datetime(2024, 1, 1, 10, 45, 12) - } - }, - } - """ - log_storage = FileStorage(log_path) - time_msgs = list(log_storage.iter_msg(tag="time_info")) - exp_gen_time_msgs = list(log_storage.iter_msg(tag="exp_gen_time_info")) - times_info = defaultdict(dict) - for msg in time_msgs: - li, fn = extract_loopid_func_name(msg.tag) - times_info[int(li)][fn] = msg.content - for msg in exp_gen_time_msgs: - li, fn = extract_loopid_func_name(msg.tag) - times_info[int(li)]["exp_gen"] = msg.content - return times_info - - -if UI_SETTING.enable_cache: - load_times_info = cache_with_pickle(_log_path_hash_func, force=True)(load_times_info) - - -def _log_folders_summary_hash_func(log_folder: str | Path, hours: int | None = None): - summary_p = Path(log_folder) / (f"summary.pkl" if hours is None else f"summary_{hours}h.pkl") - if summary_p.exists(): - hash_str = str(summary_p) + str(summary_p.stat().st_mtime) - else: - hash_str = f"{summary_p} not exists" - return md5_hash(hash_str) - - -def get_summary_df(log_folder: str | Path, hours: int | None = None) -> tuple[dict, pd.DataFrame]: - """Process experiment logs and generate summary DataFrame. - - Several key metrics that need explanation: - - * Successful Final Decision: Percentage of experiment loops where code executed correctly - and produced expected output, as determined by evaluation feedback - - * Best Result: The highest achievement level reached by any experiment throughout the entire - process, ranging from lowest to highest: made_submission, valid_submission, above_median, - bronze, silver, gold - - * SOTA Exp: Version found by working backward from the last attempt to find the most recent - successful experiment - - * SOTA Exp (to_submit): Version selected by LLM from all successful experiments for - competition submission, considering not only scores but also generalization ability - and overfitting risk, totally decided by LLM - - """ - log_folder = Path(log_folder) - sn = "summary.pkl" if hours is None else f"summary_{hours}h.pkl" - if (log_folder / sn).exists(): - summary: dict = pd.read_pickle(log_folder / sn) - else: - return {}, pd.DataFrame() - - for k, v in summary.items(): - stdout_p = log_folder / f"{k}.stdout" - if stdout_p.exists(): - v["script_time"] = get_script_time(stdout_p) - else: - v["script_time"] = None - - times_info = load_times_info(log_folder / k) - - exp_gen_time = coding_time = running_time = timedelta() - start_times, end_times = [], [] - - for loop_times in times_info.values(): - for step_name, step_time in loop_times.items(): - duration = step_time["end_time"] - step_time["start_time"] - start_times.append(step_time["start_time"]) - end_times.append(step_time["end_time"]) - - if step_name == "exp_gen": - exp_gen_time += duration - elif step_name == "coding": - coding_time += duration - elif step_name == "running": - running_time += duration - - all_time = (max(end_times) - min(start_times)) if start_times else timedelta() - v["exec_time"] = str(all_time).split(".")[0] - v["exp_gen_time"] = str(exp_gen_time).split(".")[0] - v["coding_time"] = str(coding_time).split(".")[0] - v["running_time"] = str(running_time).split(".")[0] - - # overwrite sota_exp_stat in summary.pkl because it may not be correct in multi-trace - sota_exp_submit, v["sota_loop_id_new"], sota_submit_report, v["sota_exp_stat_new"] = get_sota_exp_stat( - log_folder / k, selector="auto" - ) - sota_exp_bv, v["sota_loop_id"], sota_bv_report, v["sota_exp_stat"] = get_sota_exp_stat( - log_folder / k, selector="best_valid" - ) - ( - v["valid_improve"], - v["test_improve"], - v["submit_is_merge"], - v["merge_sota_rate"], - ) = get_score_stat(log_folder / k, v["sota_loop_id_new"]) - - if sota_exp_submit is not None: - try: - sota_submit_result = sota_exp_submit.result - except AttributeError: # Compatible with old versions - sota_submit_result = sota_exp_submit.__dict__["result"] - v["sota_exp_score_valid_new"] = ( - sota_submit_result.loc["ensemble"].iloc[0] if sota_submit_result is not None else None - ) - v["sota_exp_score"] = sota_bv_report["score"] if sota_bv_report else None - v["sota_exp_score_new"] = sota_submit_report["score"] if sota_submit_report else None - - summary = {k: v for k, v in summary.items() if "competition" in v} - base_df = pd.DataFrame( - columns=[ - "Competition", - "Total Loops", - "Best Result", - "SOTA Exp (to_submit)", - "SOTA LID (to_submit)", - "SOTA Exp Score (to_submit)", - "SOTA Exp Score (valid, to_submit)", - "SOTA Exp", - "SOTA Exp Score", - "Successful Final Decision", - "Made Submission", - "Valid Submission", - "V/M", - "Above Median", - "Bronze", - "Silver", - "Gold", - "Any Medal", - "Script Time", - "Exec Time", - "Exp Gen", - "Coding", - "Running", - "Baseline Score", - "Ours - Base", - "Ours vs Base", - "Ours vs Bronze", - "Ours vs Silver", - "Ours vs Gold", - "Bronze Threshold", - "Silver Threshold", - "Gold Threshold", - "Medium Threshold", - ], - index=summary.keys(), - ) - - # Read baseline results - baseline_result_path = UI_SETTING.baseline_result_path - if Path(baseline_result_path).exists(): - baseline_df = pd.read_csv(baseline_result_path) - - def compare_score(s1, s2): - if s1 is None or s2 is None: - return None - try: - c_value = math.exp(abs(math.log(s1 / s2))) - except Exception as e: - c_value = None - return c_value - - for k, v in summary.items(): - loop_num = v["loop_num"] - base_df.loc[k, "Competition"] = v["competition"] - base_df.loc[k, "Script Time"] = v["script_time"] - base_df.loc[k, "Exec Time"] = v["exec_time"] - base_df.loc[k, "Exp Gen"] = v["exp_gen_time"] - base_df.loc[k, "Coding"] = v["coding_time"] - base_df.loc[k, "Running"] = v["running_time"] - base_df.loc[k, "Total Loops"] = loop_num - if loop_num == 0: - base_df.loc[k] = "N/A" - else: - base_df.loc[k, "Successful Final Decision"] = v["success_loop_num"] - base_df.loc[k, "Made Submission"] = v["made_submission_num"] - if v["made_submission_num"] > 0: - base_df.loc[k, "Best Result"] = "made_submission" - base_df.loc[k, "Valid Submission"] = v["valid_submission_num"] - if v["valid_submission_num"] > 0: - base_df.loc[k, "Best Result"] = "valid_submission" - base_df.loc[k, "Above Median"] = v["above_median_num"] - if v["above_median_num"] > 0: - base_df.loc[k, "Best Result"] = "above_median" - base_df.loc[k, "Bronze"] = v["bronze_num"] - if v["bronze_num"] > 0: - base_df.loc[k, "Best Result"] = "bronze" - base_df.loc[k, "Silver"] = v["silver_num"] - if v["silver_num"] > 0: - base_df.loc[k, "Best Result"] = "silver" - base_df.loc[k, "Gold"] = v["gold_num"] - if v["gold_num"] > 0: - base_df.loc[k, "Best Result"] = "gold" - base_df.loc[k, "Any Medal"] = v["get_medal_num"] - - baseline_score = None - if Path(baseline_result_path).exists(): - baseline_score = baseline_df.loc[baseline_df["competition_id"] == v["competition"], "score"].item() - - base_df.loc[k, "SOTA Exp"] = v.get("sota_exp_stat", None) - base_df.loc[k, "SOTA Exp Score"] = v.get("sota_exp_score", None) - base_df.loc[k, "Valid Improve"] = v.get("valid_improve", None) - base_df.loc[k, "Test Improve"] = v.get("test_improve", None) - base_df.loc[k, "Submit Merge"] = v.get("submit_is_merge", None) - base_df.loc[k, "Merge Sota"] = v.get("merge_sota_rate", None) - base_df.loc[k, "SOTA Exp (to_submit)"] = v["sota_exp_stat_new"] - base_df.loc[k, "SOTA Exp Score (to_submit)"] = v.get("sota_exp_score_new", None) - base_df.loc[k, "SOTA LID (to_submit)"] = v.get("sota_loop_id_new", None) - base_df.loc[k, "SOTA Exp Score (valid, to_submit)"] = v.get("sota_exp_score_valid_new", None) - - if baseline_score is not None and v.get("sota_exp_score", None) is not None: - base_df.loc[k, "Ours - Base"] = v["sota_exp_score"] - baseline_score - base_df.loc[k, "Ours vs Base"] = compare_score(v["sota_exp_score"], baseline_score) - base_df.loc[k, "Ours vs Bronze"] = compare_score(v["sota_exp_score"], v.get("bronze_threshold", None)) - base_df.loc[k, "Ours vs Silver"] = compare_score(v["sota_exp_score"], v.get("silver_threshold", None)) - base_df.loc[k, "Ours vs Gold"] = compare_score(v["sota_exp_score"], v.get("gold_threshold", None)) - base_df.loc[k, "Baseline Score"] = baseline_score - base_df.loc[k, "Bronze Threshold"] = v.get("bronze_threshold", None) - base_df.loc[k, "Silver Threshold"] = v.get("silver_threshold", None) - base_df.loc[k, "Gold Threshold"] = v.get("gold_threshold", None) - base_df.loc[k, "Medium Threshold"] = v.get("median_threshold", None) - - base_df["SOTA Exp"] = base_df["SOTA Exp"].replace("", pd.NA) - - base_df.loc[ - base_df["SOTA Exp Score (valid, to_submit)"].apply(lambda x: isinstance(x, str)), - "SOTA Exp Score (valid, to_submit)", - ] = 0.0 - base_df = base_df.astype( - { - "Total Loops": int, - "Successful Final Decision": int, - "Made Submission": int, - "Valid Submission": int, - "Above Median": int, - "Bronze": int, - "Silver": int, - "Gold": int, - "Any Medal": int, - "Ours - Base": float, - "Ours vs Base": float, - "SOTA Exp Score": float, - "SOTA Exp Score (valid, to_submit)": float, - "Baseline Score": float, - "Bronze Threshold": float, - "Silver Threshold": float, - "Gold Threshold": float, - "Medium Threshold": float, - "Valid Improve": bool, - "Test Improve": bool, - "Submit Merge": bool, - "Merge Sota": float, - } - ) - return summary, base_df - - -if UI_SETTING.enable_cache: - get_summary_df = cache_with_pickle(_log_folders_summary_hash_func, force=True)(get_summary_df) - - -def percent_df(summary_df: pd.DataFrame, show_origin=True) -> pd.DataFrame: - """ - Convert the summary DataFrame to a percentage format. - """ - new_df = summary_df.copy(deep=True) - - # Convert columns to object dtype so we can store strings like "14 (53.85%)" without warnings - columns_to_convert = [ - "Successful Final Decision", - "Made Submission", - "Valid Submission", - "Above Median", - "Bronze", - "Silver", - "Gold", - "Any Medal", - ] - - # Filter columns_to_convert to only include columns that exist in new_df - existing_columns = [col for col in columns_to_convert if col in new_df.columns] - new_df[existing_columns] = new_df[existing_columns].astype(object) - - def num2percent(num: int, total: int, show_origin=True) -> str: - num = int(num) - total = int(total) - if show_origin: - return f"{num} ({round(num / total * 100, 2)}%)" - return f"{round(num / total * 100, 2)}%" - - for k in new_df.index: - loop_num = int(new_df.loc[k, "Total Loops"]) - if loop_num != 0: - if new_df.loc[k, "Made Submission"] != 0: - new_df.loc[k, "V/M"] = ( - f"{round(new_df.loc[k, 'Valid Submission'] / new_df.loc[k, 'Made Submission'] * 100, 2)}%" - ) - else: - new_df.loc[k, "V/M"] = "N/A" - for col in existing_columns: - new_df.loc[k, col] = num2percent(new_df.loc[k, col], loop_num, show_origin) - - return new_df - - -def get_statistics_df(summary_df: pd.DataFrame) -> pd.DataFrame: - if summary_df["Any Medal"].dtype == int: - check_value = 0 - else: - sample_val = summary_df["Any Medal"].dropna().iloc[0] - if "(" in sample_val: - check_value = "0 (0.0%)" - else: - check_value = "0.0%" - total_stat = ( - summary_df[ - [ - "Made Submission", - "Valid Submission", - "Above Median", - "Bronze", - "Silver", - "Gold", - "Any Medal", - ] - ] - != check_value - ).sum() - total_stat.name = "ๆ€ปไฝ“็ปŸ่ฎก(%)" - total_stat.loc["Bronze"] = summary_df["Best Result"].value_counts().get("bronze", 0) - total_stat.loc["Silver"] = summary_df["Best Result"].value_counts().get("silver", 0) - total_stat.loc["Gold"] = summary_df["Best Result"].value_counts().get("gold", 0) - total_stat = total_stat / summary_df.shape[0] * 100 - - # SOTA Exp ็ปŸ่ฎก - se_counts = summary_df["SOTA Exp"].value_counts(dropna=True) - se_counts.loc["made_submission"] = se_counts.sum() - se_counts.loc["Any Medal"] = se_counts.get("gold", 0) + se_counts.get("silver", 0) + se_counts.get("bronze", 0) - se_counts.loc["above_median"] = se_counts.get("above_median", 0) + se_counts.get("Any Medal", 0) - se_counts.loc["valid_submission"] = se_counts.get("valid_submission", 0) + se_counts.get("above_median", 0) - - sota_exp_stat = pd.Series(index=total_stat.index, dtype=int, name="SOTA Exp ็ปŸ่ฎก(%)") - sota_exp_stat.loc["Made Submission"] = se_counts.get("made_submission", 0) - sota_exp_stat.loc["Valid Submission"] = se_counts.get("valid_submission", 0) - sota_exp_stat.loc["Above Median"] = se_counts.get("above_median", 0) - sota_exp_stat.loc["Bronze"] = se_counts.get("bronze", 0) - sota_exp_stat.loc["Silver"] = se_counts.get("silver", 0) - sota_exp_stat.loc["Gold"] = se_counts.get("gold", 0) - sota_exp_stat.loc["Any Medal"] = se_counts.get("Any Medal", 0) - sota_exp_stat = sota_exp_stat / summary_df.shape[0] * 100 - - # SOTA Exp (trace.sota_exp_to_submit) ็ปŸ่ฎก - se_counts_new = summary_df["SOTA Exp (to_submit)"].value_counts(dropna=True) - se_counts_new.loc["made_submission"] = se_counts_new.sum() - se_counts_new.loc["Any Medal"] = ( - se_counts_new.get("gold", 0) + se_counts_new.get("silver", 0) + se_counts_new.get("bronze", 0) - ) - se_counts_new.loc["above_median"] = se_counts_new.get("above_median", 0) + se_counts_new.get("Any Medal", 0) - se_counts_new.loc["valid_submission"] = se_counts_new.get("valid_submission", 0) + se_counts_new.get( - "above_median", 0 - ) - - sota_exp_stat_new = pd.Series(index=total_stat.index, dtype=int, name="SOTA Exp (to_submit) ็ปŸ่ฎก(%)") - sota_exp_stat_new.loc["Made Submission"] = se_counts_new.get("made_submission", 0) - sota_exp_stat_new.loc["Valid Submission"] = se_counts_new.get("valid_submission", 0) - sota_exp_stat_new.loc["Above Median"] = se_counts_new.get("above_median", 0) - sota_exp_stat_new.loc["Bronze"] = se_counts_new.get("bronze", 0) - sota_exp_stat_new.loc["Silver"] = se_counts_new.get("silver", 0) - sota_exp_stat_new.loc["Gold"] = se_counts_new.get("gold", 0) - sota_exp_stat_new.loc["Any Medal"] = se_counts_new.get("Any Medal", 0) - sota_exp_stat_new = sota_exp_stat_new / summary_df.shape[0] * 100 - - stat_df = pd.concat([total_stat, sota_exp_stat, sota_exp_stat_new], axis=1) - return stat_df - - -def curve_figure(scores: pd.DataFrame) -> go.Figure: - """ - scores.columns.name is the metric name, e.g., "accuracy", "f1", etc. - scores.index is the loop index, e.g., ["L1", "L2", "L3", ...] - scores["test"] is the test score, other columns are valid scores for different loops. - The "ensemble" column is the ensemble score. - The "Test scores" and "ensemble" lines are visible, while other valid scores are hidden by default. - """ - fig = go.Figure() - fig.add_trace( - go.Scatter( - x=scores.index, - y=scores["test"], - mode="lines+markers", - name="Test scores", - marker=dict(symbol="diamond"), - line=dict(shape="linear", dash="dash"), - ) - ) - for column in scores.columns: - if column != "test": - fig.add_trace( - go.Scatter( - x=scores.index, - y=scores[column], - mode="lines+markers", - name=f"{column}", - visible=("legendonly" if column != "ensemble" else None), - ) - ) - fig.update_layout(title=f"Test and Valid scores (metric: {scores.columns.name})") - - return fig - - -def lite_curve_figure(summary): - cols = 3 # ๆฏ่กŒๅ‡ ไธชๅ›พ๏ผŒๅฏ่ฐƒๆ•ด - rows = math.ceil(len(summary) / cols) - - fig, axes = plt.subplots(rows, cols, figsize=(6 * cols, 4.5 * rows), squeeze=False) - axes = axes.flatten() # ๐Ÿ’ก ๆ‰ๅนณๅŒ– axes ็ป“ๆž„๏ผŒ็กฎไฟ ax.plot ไธๆŠฅ้”™ - colors = {"Bronze": "#cd7f32", "Silver": "#c0c0c0", "Gold": "#ffd700", "Median": "gray"} - - for idx, competition in enumerate(summary.keys()): - data = summary[competition] - test_scores_df = pd.DataFrame.from_dict(data["test_scores"], orient="index", columns=["Test Score"]) - test_scores_df.index.name = "Loop" - valid_scores_dict = data["valid_scores"] - - # ๆๅ– ensemble ้ชŒ่ฏๅˆ†ๆ•ฐ - ensemble_scores = {} - for loop_id, df in valid_scores_dict.items(): - if "ensemble" in df.index: - ensemble_scores[loop_id] = df.loc["ensemble"].iloc[0] - - ensemble_valid_df = pd.DataFrame.from_dict(ensemble_scores, orient="index", columns=["Ensemble Valid Score"]) - ensemble_valid_df.index.name = "Loop" - - combined_df = pd.merge(ensemble_valid_df, test_scores_df, left_index=True, right_index=True, how="outer") - combined_df.sort_index(inplace=True) - - bronze_threshold = data["bronze_threshold"] - silver_threshold = data["silver_threshold"] - gold_threshold = data["gold_threshold"] - sota_loop_id = data["sota_loop_id_new"] - - # ๅฝ“ๅ‰ subplot - ax = axes[idx] - ax.plot(combined_df.index, combined_df["Ensemble Valid Score"], marker="o", markersize=4, label="Valid Score") - ax.plot(combined_df.index, combined_df["Test Score"], marker="s", markersize=4, label="Test Score") - ax.axhline(y=bronze_threshold, color=colors["Bronze"], linestyle="--", linewidth=2) - ax.axhline(y=silver_threshold, color=colors["Silver"], linestyle="--", linewidth=2) - ax.axhline(y=gold_threshold, color=colors["Gold"], linestyle="--", linewidth=2) - - # ๆ ‡่ฎฐ SOTA loop - if sota_loop_id is not None and sota_loop_id in combined_df.index: - ax.axvline(x=sota_loop_id, color="red", linestyle=":", linewidth=2, alpha=0.7) - # ๆทปๅŠ ๆ–‡ๆœฌๆ ‡ๆณจ - ax.text( - sota_loop_id, - ax.get_ylim()[1] * 0.95, - f"L{sota_loop_id}", - ha="center", - va="top", - bbox=dict(boxstyle="round,pad=0.3", facecolor="red", alpha=0.3), - ) - - ax.set_title(f"{competition}") - ax.set_xlabel("Loop") - ax.set_ylabel("Score") - ax.grid(True) - ax.legend() - - # ๅˆ ้™คๅคšไฝ™ subplot๏ผˆๅฆ‚ๆžœๆœ‰๏ผ‰ - for j in range(len(summary), len(axes)): - fig.delaxes(axes[j]) - - plt.tight_layout() - return fig - - -def trace_figure(trace: Trace, merge_loops: list = []): - G = nx.DiGraph() - - # Calculate the number of ancestors for each node (root node is 0, more ancestors means lower level) - levels = {} - for i in range(len(trace.dag_parent)): - levels[i] = len(trace.get_parents(i)) - - def get_display_name(idx: int): - """ - Convert to index in the queue (enque id) to loop_idx for easier understanding. - """ - if hasattr(trace, "idx2loop_id") and idx in trace.idx2loop_id: - # FIXME: only keep me after it is stable. Just for compatibility. - return f"L{trace.idx2loop_id[idx]} ({idx})" - return f"L{idx}" - - # Add nodes and edges - edges = [] - parents_record = {} - for i, parents in enumerate(trace.dag_parent): - for parent in parents: - edges.append((get_display_name(parent), get_display_name(i))) - if len(parents) == 0: - G.add_node(get_display_name(i)) - parents_record[get_display_name(i)] = [get_display_name(parent) for parent in parents] - G.add_edges_from(edges) - - # Check if G is a path (a single line) - is_path = nx.is_path(G, list(nx.topological_sort(G))) - if is_path: - # Arrange nodes in a square spiral - n = len(G.nodes()) - pos = {} - x, y = 0, 0 - dx, dy = 1, 0 - step = 1 - steps_taken = 0 - steps_in_dir = 1 - dir_changes = 0 - for i, node in enumerate(G.nodes()): - pos[node] = (x, y) - x += dx - y += dy - steps_taken += 1 - if steps_taken == steps_in_dir: - steps_taken = 0 - # Change direction: right -> up -> left -> down -> right ... - dx, dy = -dy, dx - dir_changes += 1 - if dir_changes % 2 == 0: - steps_in_dir += 1 - else: - # Group nodes by number of ancestors, fewer ancestors are higher up - layer_nodes = {} - for idx, lvl in levels.items(): - layer_nodes.setdefault(lvl, []).append(get_display_name(idx)) - - # Layout by level: y axis is -lvl, x axis is evenly distributed - pos = {} - - def parent_avg_pos(node): - parent_nodes = parents_record.get(node, []) - parent_xs = [pos[p][0] for p in parent_nodes if p in pos] - return sum(parent_xs) / len(parent_xs) if parent_xs else 0 - - for lvl in sorted(layer_nodes): - nodes = layer_nodes[lvl] - # For root nodes, sort directly by index - if lvl == min(layer_nodes): - sorted_nodes = sorted(nodes, key=lambda n: int(n[1:].split(" ")[0])) - else: - # Sort by average parent x, so children are below their parents - sorted_nodes = sorted(nodes, key=parent_avg_pos) - y = -lvl # y decreases as level increases (children below parents) - for i, node in enumerate(sorted_nodes): - if lvl == min(layer_nodes): - x = i - else: - # Place child directly below average parent x, offset if multiple at same y - avg_x = parent_avg_pos(node) - # To avoid overlap, spread siblings a bit if needed - x = avg_x + (i - (len(sorted_nodes) - 1) / 2) * 0.5 - pos[node] = (x, y) - - fig, ax = plt.subplots(figsize=(8, 6)) - color_map = ["tomato" if node in [get_display_name(idx) for idx in merge_loops] else "skyblue" for node in G] - nx.draw(G, pos, with_labels=True, arrows=True, node_color=color_map, node_size=100, font_size=5, ax=ax) - return fig - - -def timeline_figure(times_dict: dict[int, dict[str, dict[Literal["start_time", "end_time"], datetime]]]) -> go.Figure: - # Prepare data for px.timeline - timeline_data = [] - step_names = ["exp_gen", "coding", "running", "feedback", "record"] - - # Beautiful color palette with gradients - colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA726", "#5A0069"] - color_map = {step: color for step, color in zip(step_names, colors)} - - for loop_id, steps in times_dict.items(): - for step_name, timing in steps.items(): - if step_name in step_names: - duration = timing["end_time"] - timing["start_time"] - timeline_data.append( - { - "Start": timing["start_time"], - "Finish": timing["end_time"], - "Step": step_name, - "Loop_ID": f"Loop {loop_id}", - "Duration": str(duration).split(".")[0], # Remove microseconds - } - ) - - # Create DataFrame and sort by loop ID in descending order - df = pd.DataFrame(timeline_data) - df["loop_sort"] = df["Loop_ID"].str.extract("(\d+)").astype(int) - df = df.sort_values("loop_sort", ascending=False) - - # Create timeline with enhanced styling - fig = px.timeline( - df, - x_start="Start", - x_end="Finish", - y="Loop_ID", - color="Step", - color_discrete_map=color_map, - title="๐Ÿš€ Data Science Loop Timeline", - hover_data={"Duration": True, "Loop_ID": False, "Step": False}, - hover_name="Step", - ) - - # Enhanced styling and layout - fig.update_traces( - marker=dict(line=dict(width=1, color="rgba(255,255,255,0.8)"), opacity=0.85), - width=0.9, # Increased from 0.8 to make bars thicker and reduce spacing - hovertemplate="%{hovertext}
" - + "Start: %{base}
" - + "End: %{x}
" - + "Duration: %{customdata[0]}
" - + "", - ) - - # Beautiful layout with gradients and shadows - fig.update_layout( - title=dict(text="Data Science Loop Timeline", x=0.0, font=dict(size=24, color="#2C3E50", family="Arial Black")), - xaxis=dict( - title="โฐ Time", - showgrid=True, - gridwidth=1, - gridcolor="rgba(176, 196, 222, 0.4)", - zeroline=False, - tickfont=dict(size=12, color="#34495E"), - title_font=dict(size=14, color="#2C3E50", family="Arial"), - ), - yaxis=dict( - title="๐Ÿ”„ Loop ID", - showgrid=True, - gridwidth=1, - gridcolor="rgba(176, 196, 222, 0.4)", - zeroline=False, - tickfont=dict(size=12, color="#34495E"), - title_font=dict(size=14, color="#2C3E50", family="Arial"), - ), - plot_bgcolor="rgba(248, 249, 250, 0.8)", - paper_bgcolor="white", - height=max(200, len(times_dict) * 25), # Reduced from 300 and 30 to 200 and 25 - margin=dict(l=100, r=60, t=80, b=60), - legend=dict( - x=0.98, - y=0.98, - xanchor="right", - yanchor="top", - bgcolor="rgba(255,255,255,0.9)", - bordercolor="rgba(0,0,0,0.2)", - borderwidth=1, - title_font=dict(size=12, color="#2C3E50"), - font=dict(size=11, color="#34495E"), - traceorder="normal", - ), - font=dict(family="Arial, sans-serif"), - template="plotly_white", - ) - - # Reorder legend to match step_names order - fig.data = sorted( - fig.data, key=lambda trace: step_names.index(trace.name) if trace.name in step_names else len(step_names) - ) - - # Add subtle shadow effect - fig.add_shape( - type="rect", - xref="paper", - yref="paper", - x0=0, - y0=0, - x1=1, - y1=1, - line=dict(color="rgba(0,0,0,0.1)", width=2), - fillcolor="rgba(0,0,0,0.02)", - ) - - return fig - - -def compare( - exp_list: list[str] = typer.Option(..., "--exp-list", help="List of experiment names.", show_default=False), - output: str = typer.Option("merge_base_df.h5", help="Output summary file name."), - hours: int | None = typer.Option(None, help="if None, use summary.pkl, else summary_{hours}h.pkl"), - select_best: bool = typer.Option(False, help="Select best experiment for each competition."), -): - """ - Generate summary and base dataframe for given experiment list, and save to a summary file. - """ - typer.secho(f"exp_list: {exp_list}", fg=typer.colors.GREEN) - log_folders = [f"{UI_SETTING.amlt_path}/{exp}/combined_logs" for exp in exp_list] - summary, base_df = get_summary_df(log_folders, hours=hours) - if select_best: - - def apply_func(cdf: pd.DataFrame): - cp = cdf["Competition"].values[0] - md = get_metric_direction(cp) - # If SOTA Exp Score (valid, to_submit) column is empty, return the first index - if cdf["SOTA Exp Score (valid, to_submit)"].dropna().empty: - return cdf.index[0] - if md: - best_idx = cdf["SOTA Exp Score (valid, to_submit)"].idxmax() - else: - best_idx = cdf["SOTA Exp Score (valid, to_submit)"].idxmin() - return best_idx - - best_idxs = base_df.groupby("Competition").apply(apply_func) - base_df = base_df[base_df.index.isin(best_idxs.values)] - summary = {k: v for k, v in summary.items() if k in best_idxs.values.tolist()} - typer.secho(f"Summary keys: {list(summary.keys())}", fg=typer.colors.CYAN) - typer.secho("Summary DataFrame:", fg=typer.colors.MAGENTA) - typer.secho(str(base_df), fg=typer.colors.YELLOW) - base_df.to_hdf(output, "data") - typer.secho(f"Summary saved to {output}", fg=typer.colors.GREEN) - - -if __name__ == "__main__": - app = typer.Typer() - app.command()(compare) - app() diff --git a/rdagent/log/ui/web.py b/rdagent/log/ui/web.py deleted file mode 100644 index bdfe0d8f..00000000 --- a/rdagent/log/ui/web.py +++ /dev/null @@ -1,629 +0,0 @@ -import time -from collections import defaultdict -from copy import deepcopy -from datetime import datetime, timezone -from typing import Callable, Type - -import pandas as pd -import plotly.express as px -import streamlit as st -from streamlit.delta_generator import DeltaGenerator - -from rdagent.components.coder.factor_coder.evaluators import FactorSingleFeedback -from rdagent.components.coder.factor_coder.factor import FactorFBWorkspace, FactorTask -from rdagent.components.coder.model_coder.evaluators import ModelSingleFeedback -from rdagent.components.coder.model_coder.model import ModelFBWorkspace, ModelTask -from rdagent.core.proposal import Hypothesis, HypothesisFeedback, Trace -from rdagent.log.base import Message, Storage, View -from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorExperiment -from rdagent.scenarios.qlib.experiment.model_experiment import ( - QlibModelExperiment, - QlibModelScenario, -) - -st.set_page_config(layout="wide") - -TIME_DELAY = 0.001 - - -class WebView(View): - def __init__(self, ui: "StWindow"): - self.ui = ui - # Save logs to your desired data structure - # ... - - def display(self, s: Storage, watch: bool = False): - for msg in s.iter_msg(): # iterate overtime - # NOTE: iter_msg will correctly separate the information. - # TODO: msg may support streaming mode. - self.ui.consume_msg(msg) - - -class StWindow: - def __init__(self, container: "DeltaGenerator"): - self.container = container - - def consume_msg(self, msg: Message): - msg_str = f"{msg.timestamp.astimezone(timezone.utc).isoformat()} | {msg.level} | {msg.caller} - {msg.content}" - self.container.code(msg_str, language="log") - - -class LLMWindow(StWindow): - def __init__(self, container: "DeltaGenerator", session_name: str = "common"): - self.session_name = session_name - self.container = container.expander(f"{self.session_name} message") - - def consume_msg(self, msg: Message): - self.container.chat_message("user").markdown(f"{msg.content}") - - -class ProgressTabsWindow(StWindow): - """ - For windows with stream messages, will refresh when a new tab is created. - """ - - def __init__( - self, - container: "DeltaGenerator", - inner_class: Type[StWindow] = StWindow, - mapper: Callable[[Message], str] = lambda x: x.pid_trace, - ): - self.inner_class = inner_class - self.mapper = mapper - - self.container = container.empty() - self.tab_windows: dict[str, StWindow] = defaultdict(None) - self.tab_caches: dict[str, list[Message]] = defaultdict(list) - - def consume_msg(self, msg: Message): - name = self.mapper(msg) - - if name not in self.tab_windows: - # new tab need to be created, current streamlit container need to be updated. - names = list(self.tab_windows.keys()) + [name] - - if len(names) == 1: - tabs = [self.container.container()] - else: - tabs = self.container.tabs(names) - - for id, name in enumerate(names): - self.tab_windows[name] = self.inner_class(tabs[id]) - - # consume the cache - for name in self.tab_caches: - for msg in self.tab_caches[name]: - self.tab_windows[name].consume_msg(msg) - - self.tab_caches[name].append(msg) - self.tab_windows[name].consume_msg(msg) - - -class ObjectsTabsWindow(StWindow): - def __init__( - self, - container: "DeltaGenerator", - inner_class: Type[StWindow] = StWindow, - mapper: Callable[[object], str] = lambda x: str(x), - tab_names: list[str] | None = None, - ): - self.inner_class = inner_class - self.mapper = mapper - self.container = container - self.tab_names = tab_names - - def consume_msg(self, msg: Message): - if isinstance(msg.content, list): - if self.tab_names: - assert len(self.tab_names) == len( - msg.content - ), "List of objects should have the same length as provided tab names." - objs_dict = {self.tab_names[id]: obj for id, obj in enumerate(msg.content)} - else: - objs_dict = {self.mapper(obj): obj for obj in msg.content} - elif not isinstance(msg.content, dict): - raise ValueError("Message content should be a list or a dict of objects.") - - # two many tabs may cause display problem - tab_names = list(objs_dict.keys()) - tabs = [] - for i in range(0, len(tab_names), 10): - tabs.extend(self.container.tabs(tab_names[i : i + 10])) - - for id, obj in enumerate(objs_dict.values()): - splited_msg = Message( - tag=msg.tag, - level=msg.level, - timestamp=msg.timestamp, - caller=msg.caller, - pid_trace=msg.pid_trace, - content=obj, - ) - self.inner_class(tabs[id]).consume_msg(splited_msg) - - -class RoundTabsWindow(StWindow): - def __init__( - self, - container: "DeltaGenerator", - new_tab_func: Callable[[Message], bool], - inner_class: Type[StWindow] = StWindow, - title: str = "Round tabs", - ): - container.markdown(f"### **{title}**") - self.inner_class = inner_class - self.new_tab_func = new_tab_func - self.round = 0 - - self.current_win = StWindow(container) - self.tabs_c = container.empty() - - def consume_msg(self, msg: Message): - if self.new_tab_func(msg): - self.round += 1 - self.current_win = self.inner_class(self.tabs_c.tabs([str(i) for i in range(1, self.round + 1)])[-1]) - - self.current_win.consume_msg(msg) - - -class HypothesisWindow(StWindow): - def consume_msg(self, msg: Message | Hypothesis): - h: Hypothesis = msg.content if isinstance(msg, Message) else msg - - self.container.markdown("#### **Hypothesis๐Ÿ’ก**") - self.container.markdown(f""" -- **Hypothesis**: {h.hypothesis} -- **Reason**: {h.reason}""") - - -class HypothesisFeedbackWindow(StWindow): - def consume_msg(self, msg: Message | HypothesisFeedback): - h: HypothesisFeedback = msg.content if isinstance(msg, Message) else msg - - self.container.markdown("#### **Hypothesis Feedback๐Ÿ”**") - self.container.markdown(f""" -- **Observations**: {h.observations} -- **Hypothesis Evaluation**: {h.hypothesis_evaluation} -- **New Hypothesis**: {h.new_hypothesis} -- **Decision**: {h.decision} -- **Reason**: {h.reason}""") - - -class FactorTaskWindow(StWindow): - def consume_msg(self, msg: Message | FactorTask): - ft: FactorTask = msg.content if isinstance(msg, Message) else msg - - self.container.markdown(f"**Factor Name**: {ft.factor_name}") - self.container.markdown(f"**Description**: {ft.factor_description}") - self.container.latex(f"Formulation: {ft.factor_formulation}") - - variables_df = pd.DataFrame(ft.variables, index=["Description"]).T - variables_df.index.name = "Variable" - self.container.table(variables_df) - self.container.text(f"Factor resources: {ft.factor_resources}") - - -class ModelTaskWindow(StWindow): - def consume_msg(self, msg: Message | ModelTask): - mt: ModelTask = msg.content if isinstance(msg, Message) else msg - - self.container.markdown(f"**Model Name**: {mt.name}") - self.container.markdown(f"**Model Type**: {mt.model_type}") - self.container.markdown(f"**Description**: {mt.description}") - self.container.latex(f"Formulation: {mt.formulation}") - - variables_df = pd.DataFrame(mt.variables, index=["Value"]).T - variables_df.index.name = "Variable" - self.container.table(variables_df) - - -class FactorFeedbackWindow(StWindow): - def consume_msg(self, msg: Message | FactorSingleFeedback): - fb: FactorSingleFeedback = msg.content if isinstance(msg, Message) else msg - - self.container.markdown(f"""### :blue[Factor Execution Feedback] -{fb.execution_feedback} -### :blue[Factor Code Feedback] -{fb.code_feedback} -### :blue[Factor Value Feedback] -{fb.value_feedback} -### :blue[Factor Final Feedback] -{fb.final_feedback} -### :blue[Factor Final Decision] -This implementation is {'SUCCESS' if fb.final_decision else 'FAIL'}. -""") - - -class ModelFeedbackWindow(StWindow): - def consume_msg(self, msg: Message | ModelSingleFeedback): - mb: ModelSingleFeedback = msg.content if isinstance(msg, Message) else msg - - self.container.markdown(f"""### :blue[Model Execution Feedback] -{mb.execution_feedback} -### :blue[Model Shape Feedback] -{mb.shape_feedback} -### :blue[Model Value Feedback] -{mb.value_feedback} -### :blue[Model Code Feedback] -{mb.code_feedback} -### :blue[Model Final Feedback] -{mb.final_feedback} -### :blue[Model Final Decision] -This implementation is {'SUCCESS' if mb.final_decision else 'FAIL'}. -""") - - -class WorkspaceWindow(StWindow): - def __init__(self, container: "DeltaGenerator", show_task_info: bool = False): - self.container = container - self.show_task_info = show_task_info - - def consume_msg(self, msg: Message | FactorFBWorkspace | ModelFBWorkspace): - ws: FactorFBWorkspace | ModelFBWorkspace = msg.content if isinstance(msg, Message) else msg - - # no workspace - if ws is None: - return - - # task info - if self.show_task_info: - task_msg = deepcopy(msg) - task_msg.content = ws.target_task - if isinstance(ws, FactorFBWorkspace): - self.container.subheader("Factor Info") - FactorTaskWindow(self.container.container()).consume_msg(task_msg) - else: - self.container.subheader("Model Info") - ModelTaskWindow(self.container.container()).consume_msg(task_msg) - - # task codes - for k, v in ws.file_dict.items(): - self.container.markdown(f"`{k}`") - self.container.code(v, language="python") - - -class QlibFactorExpWindow(StWindow): - def __init__(self, container: DeltaGenerator, show_task_info: bool = False): - self.container = container - self.show_task_info = show_task_info - - def consume_msg(self, msg: Message | QlibFactorExperiment): - exp: QlibFactorExperiment = msg.content if isinstance(msg, Message) else msg - - # factor tasks - if self.show_task_info: - ftm_msg = deepcopy(msg) - ftm_msg.content = [ws for ws in exp.sub_workspace_list if ws] - self.container.markdown("**Factor Tasks**") - ObjectsTabsWindow( - self.container.container(), - inner_class=WorkspaceWindow, - mapper=lambda x: x.target_task.factor_name, - ).consume_msg(ftm_msg) - - # result - self.container.markdown("**Results**") - results = pd.DataFrame({f"base_exp_{id}": e.result for id, e in enumerate(exp.based_experiments)}) - results["now"] = exp.result - - self.container.expander("results table").table(results) - - try: - bar_chart = px.bar(results, orientation="h", barmode="group") - self.container.expander("results chart").plotly_chart(bar_chart) - except: - self.container.text("Results are incomplete.") - - -class QlibModelExpWindow(StWindow): - def __init__(self, container: DeltaGenerator, show_task_info: bool = False): - self.container = container - self.show_task_info = show_task_info - - def consume_msg(self, msg: Message | QlibModelExperiment): - exp: QlibModelExperiment = msg.content if isinstance(msg, Message) else msg - - # model tasks - if self.show_task_info: - _msg = deepcopy(msg) - _msg.content = [ws for ws in exp.sub_workspace_list if ws] - self.container.markdown("**Model Tasks**") - ObjectsTabsWindow( - self.container.container(), - inner_class=WorkspaceWindow, - mapper=lambda x: x.target_task.name, - ).consume_msg(_msg) - - # result - self.container.subheader("Results", divider=True) - results = pd.DataFrame({f"base_exp_{id}": e.result for id, e in enumerate(exp.based_experiments)}) - results["now"] = exp.result - - self.container.expander("results table").table(results) - - -class SimpleTraceWindow(StWindow): - def __init__( - self, container: "DeltaGenerator" = st.container(), show_llm: bool = False, show_common_logs: bool = False - ): - super().__init__(container) - self.show_llm = show_llm - self.show_common_logs = show_common_logs - self.pid_trace = "" - self.current_tag = "" - - self.current_win = StWindow(self.container) - self.evolving_tasks: list[str] = [] - - def consume_msg(self, msg: Message): - # divide tag levels - if len(msg.tag) > len(self.current_tag): - # write a header about current task, if it is llm message, not write. - if not msg.tag.endswith("llm_messages"): - self.container.header(msg.tag.replace(".", " โžก "), divider=True) - - self.current_tag = msg.tag - - # set log writer (window) according to msg - if msg.tag.endswith("llm_messages"): - # llm messages logs - if not self.show_llm: - return - if not isinstance(self.current_win, LLMWindow): - self.current_win = LLMWindow(self.container) - elif isinstance(msg.content, Hypothesis): - # hypothesis - self.current_win = HypothesisWindow(self.container) - elif isinstance(msg.content, HypothesisFeedback): - # hypothesis feedback - self.current_win = HypothesisFeedbackWindow(self.container) - elif isinstance(msg.content, QlibFactorExperiment): - self.current_win = QlibFactorExpWindow(self.container) - elif isinstance(msg.content, QlibModelExperiment): - self.current_win = QlibModelExpWindow(self.container) - elif isinstance(msg.content, list): - msg.content = [m for m in msg.content if m] - if len(msg.content) == 0: - return - if isinstance(msg.content[0], FactorTask): - self.current_win = ObjectsTabsWindow( - self.container.expander("Factor Tasks"), FactorTaskWindow, lambda x: x.factor_name - ) - elif isinstance(msg.content[0], ModelTask): - self.current_win = ObjectsTabsWindow( - self.container.expander("Model Tasks"), ModelTaskWindow, lambda x: x.name - ) - - elif isinstance(msg.content[0], FactorFBWorkspace): - self.current_win = ObjectsTabsWindow( - self.container.expander("Factor Workspaces"), - inner_class=WorkspaceWindow, - mapper=lambda x: x.target_task.factor_name, - ) - self.evolving_tasks = [m.target_task.factor_name for m in msg.content] - elif isinstance(msg.content[0], ModelFBWorkspace): - self.current_win = ObjectsTabsWindow( - self.container.expander("Model Workspaces"), - inner_class=WorkspaceWindow, - mapper=lambda x: x.target_task.name, - ) - self.evolving_tasks = [m.target_task.name for m in msg.content] - - elif isinstance(msg.content[0], FactorSingleFeedback): - self.current_win = ObjectsTabsWindow( - self.container.expander("Factor Feedbacks"), - inner_class=FactorFeedbackWindow, - tab_names=self.evolving_tasks, - ) - elif isinstance(msg.content[0], ModelSingleFeedback): - self.current_win = ObjectsTabsWindow( - self.container.expander("Model Feedbacks"), - inner_class=ModelFeedbackWindow, - tab_names=self.evolving_tasks, - ) - else: - # common logs - if not self.show_common_logs: - return - self.current_win = StWindow(self.container) - - self.current_win.consume_msg(msg) - - -def mock_msg(obj) -> Message: - return Message(tag="mock", level="INFO", timestamp=datetime.now(), pid_trace="000", caller="mock", content=obj) - - -class TraceObjWindow(StWindow): - def __init__(self, container: "DeltaGenerator" = st.container()): - self.container = container - - def consume_msg(self, msg: Message | Trace): - if isinstance(msg, Message): - trace: Trace = msg.content - else: - trace = msg - - for id, (h, e, hf) in enumerate(trace.hist): - self.container.header(f"Trace History {id}", divider=True) - HypothesisWindow(self.container).consume_msg(mock_msg(h)) - if isinstance(e, QlibFactorExperiment): - QlibFactorExpWindow(self.container).consume_msg(mock_msg(e)) - else: - QlibModelExpWindow(self.container).consume_msg(mock_msg(e)) - HypothesisFeedbackWindow(self.container).consume_msg(mock_msg(hf)) - - -class ResearchWindow(StWindow): - def consume_msg(self, msg: Message): - if msg.tag.endswith("hypothesis generation"): - HypothesisWindow(self.container.container()).consume_msg(msg) - elif msg.tag.endswith("experiment generation"): - if isinstance(msg.content, list): - if isinstance(msg.content[0], FactorTask): - self.container.markdown("**Factor Tasks**") - ObjectsTabsWindow( - self.container.container(), FactorTaskWindow, lambda x: x.factor_name - ).consume_msg(msg) - elif isinstance(msg.content[0], ModelTask): - self.container.markdown("**Model Tasks**") - ObjectsTabsWindow(self.container.container(), ModelTaskWindow, lambda x: x.name).consume_msg(msg) - elif msg.tag.endswith("load_pdf_screenshot"): - self.container.image(msg.content) - elif msg.tag.endswith("load_factor_tasks"): - self.container.json(msg.content) - - -class EvolvingWindow(StWindow): - def __init__(self, container: "DeltaGenerator"): - self.container = container - self.evolving_tasks: list[str] = [] - - def consume_msg(self, msg: Message): - if msg.tag.endswith("evolving code"): - if isinstance(msg.content, list): - msg.content = [m for m in msg.content if m] - if len(msg.content) == 0: - return - if isinstance(msg.content[0], FactorFBWorkspace): - self.container.markdown("**Factor Codes**") - ObjectsTabsWindow( - self.container.container(), - inner_class=WorkspaceWindow, - mapper=lambda x: x.target_task.factor_name, - ).consume_msg(msg) - self.evolving_tasks = [m.target_task.factor_name for m in msg.content] - elif isinstance(msg.content[0], ModelFBWorkspace): - self.container.markdown("**Model Codes**") - ObjectsTabsWindow( - self.container.container(), inner_class=WorkspaceWindow, mapper=lambda x: x.target_task.name - ).consume_msg(msg) - self.evolving_tasks = [m.target_task.name for m in msg.content] - elif msg.tag.endswith("evolving feedback"): - if isinstance(msg.content, list): - msg.content = [m for m in msg.content if m] - if len(msg.content) == 0: - return - if isinstance(msg.content[0], FactorSingleFeedback): - self.container.markdown("**Factor Feedbacks๐Ÿ”**") - ObjectsTabsWindow( - self.container.container(), inner_class=FactorFeedbackWindow, tab_names=self.evolving_tasks - ).consume_msg(msg) - elif isinstance(msg.content[0], ModelSingleFeedback): - self.container.markdown("**Model Feedbacks๐Ÿ”**") - ObjectsTabsWindow( - self.container.container(), inner_class=ModelFeedbackWindow, tab_names=self.evolving_tasks - ).consume_msg(msg) - - -class DevelopmentWindow(StWindow): - def __init__(self, container: "DeltaGenerator"): - self.E_win = RoundTabsWindow( - container.container(), - new_tab_func=lambda x: x.tag.endswith("evolving code"), - inner_class=EvolvingWindow, - title="Evolving Loops๐Ÿ”ง", - ) - - def consume_msg(self, msg: Message): - if "evolving" in msg.tag: - self.E_win.consume_msg(msg) - - -class FeedbackWindow(StWindow): - def __init__(self, container: "DeltaGenerator"): - self.container = container - - def consume_msg(self, msg: Message): - if msg.tag.endswith("returns"): - fig = px.line(msg.content) - self.container.markdown("**Returns๐Ÿ“ˆ**") - self.container.plotly_chart(fig) - elif isinstance(msg.content, HypothesisFeedback): - HypothesisFeedbackWindow(self.container.container(border=True)).consume_msg(msg) - elif isinstance(msg.content, QlibModelExperiment): - QlibModelExpWindow(self.container.container(border=True)).consume_msg(msg) - elif isinstance(msg.content, QlibFactorExperiment): - QlibFactorExpWindow(self.container.container(border=True)).consume_msg(msg) - - -class SingleRDLoopWindow(StWindow): - def __init__(self, container: "DeltaGenerator"): - self.container = container - col1, col2 = self.container.columns([2, 3]) - self.R_win = ResearchWindow(col1.container(border=True)) - self.F_win = FeedbackWindow(col1.container(border=True)) - self.D_win = DevelopmentWindow(col2.container(border=True)) - - def consume_msg(self, msg: Message): - tags = msg.tag.split(".") - if "r" in tags: - self.R_win.consume_msg(msg) - elif "d" in tags: - self.D_win.consume_msg(msg) - elif "ef" in tags: - self.F_win.consume_msg(msg) - - -class TraceWindow(StWindow): - def __init__( - self, container: "DeltaGenerator" = st.container(), show_llm: bool = False, show_common_logs: bool = False - ): - self.show_llm = show_llm - self.show_common_logs = show_common_logs - image_c, scen_c = container.columns([2, 3], vertical_alignment="center") - image_c.image("scen.png") - scen_c.container(border=True).markdown(QlibModelScenario().rich_style_description) - top_container = container.container() - col1, col2 = top_container.columns([2, 3]) - chart_c = col2.container(border=True, height=500) - chart_c.markdown("**Metrics๐Ÿ“ˆ**") - self.chart_c = chart_c.empty() - hypothesis_status_c = col1.container(border=True, height=500) - hypothesis_status_c.markdown("**Hypotheses๐Ÿ…**") - self.summary_c = hypothesis_status_c.empty() - - self.RDL_win = RoundTabsWindow( - container.container(), - new_tab_func=lambda x: x.tag.endswith("hypothesis generation"), - inner_class=SingleRDLoopWindow, - title="R&D Loopsโ™พ๏ธ", - ) - - self.hypothesis_decisions = defaultdict(bool) - self.hypotheses: list[Hypothesis] = [] - - self.results = [] - - def consume_msg(self, msg: Message): - if not self.show_llm and "llm_messages" in msg.tag: - return - if not self.show_common_logs and isinstance(msg.content, str): - return - if isinstance(msg.content, dict): - return - if msg.tag.endswith("hypothesis generation"): - self.hypotheses.append(msg.content) - elif msg.tag.endswith("ef.feedback"): - self.hypothesis_decisions[self.hypotheses[-1]] = msg.content.decision - self.summary_c.markdown( - "\n".join( - ( - f"{id+1}. :green[{self.hypotheses[id].hypothesis}]\n\t>*{self.hypotheses[id].concise_reason}*" - if d - else f"{id+1}. {self.hypotheses[id].hypothesis}\n\t>*{self.hypotheses[id].concise_reason}*" - ) - for id, (h, d) in enumerate(self.hypothesis_decisions.items()) - ) - ) - elif msg.tag.endswith("ef.model runner result") or msg.tag.endswith("ef.factor runner result"): - self.results.append(msg.content.result) - if len(self.results) == 1: - self.chart_c.table(self.results[0]) - else: - df = pd.DataFrame(self.results, index=range(1, len(self.results) + 1)) - fig = px.line(df, x=df.index, y=df.columns, markers=True) - self.chart_c.plotly_chart(fig) - - self.RDL_win.consume_msg(msg) - # time.sleep(TIME_DELAY) diff --git a/rdagent/log/utils/__init__.py b/rdagent/log/utils/__init__.py deleted file mode 100644 index 44edb67e..00000000 --- a/rdagent/log/utils/__init__.py +++ /dev/null @@ -1,129 +0,0 @@ -import inspect -import json -import re -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Optional, TypedDict, cast - - -class LogColors: - """ - ANSI color codes for use in console output. - """ - - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - MAGENTA = "\033[95m" - CYAN = "\033[96m" - WHITE = "\033[97m" - GRAY = "\033[90m" - BLACK = "\033[30m" - - BOLD = "\033[1m" - ITALIC = "\033[3m" - - END = "\033[0m" - - @classmethod - def get_all_colors(cls: type["LogColors"]) -> list: - names = dir(cls) - names = [name for name in names if not name.startswith("__") and not callable(getattr(cls, name))] - return [getattr(cls, name) for name in names] - - def render(self, text: str, color: str = "", style: str = "") -> str: - """ - render text by input color and style. - It's not recommend that input text is already rendered. - """ - # This method is called too frequently, which is not good. - colors = self.get_all_colors() - # Perhaps color and font should be distinguished here. - if color and color in colors: - error_message = f"color should be in: {colors} but now is: {color}" - raise ValueError(error_message) - if style and style in colors: - error_message = f"style should be in: {colors} but now is: {style}" - raise ValueError(error_message) - - text = f"{color}{text}{self.END}" - - return f"{style}{text}{self.END}" - - @staticmethod - def remove_ansi_codes(s: str) -> str: - """ - It is for removing ansi ctrl characters in the string(e.g. colored text) - """ - ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") - return ansi_escape.sub("", s) - - -class CallerInfo(TypedDict): - function: str - line: int - name: Optional[str] - - -def get_caller_info(level: int = 2) -> CallerInfo: - # Get the current stack information - stack = inspect.stack() - # The second element is usually the caller's information - caller_info = stack[level] - frame = caller_info[0] - info: CallerInfo = { - "line": caller_info.lineno, - "name": frame.f_globals["__name__"], # Get the module name from the frame's globals - "function": frame.f_code.co_name, # Get the caller's function name - } - return info - - -def is_valid_session(log_path: Path) -> bool: - return log_path.is_dir() and log_path.joinpath("__session__").exists() - - -def extract_loopid_func_name(tag: str) -> tuple[str, str] | tuple[None, None]: - """extract loop id and function name from the tag in Message""" - match = re.search(r"Loop_(\d+)\.([^.]+)", tag) - return cast(tuple[str, str], match.groups()) if match else (None, None) - - -def extract_evoid(tag: str) -> str | None: - """extract evo id from the tag in Message""" - match = re.search(r"evo_loop_(\d+)\.", tag) - return cast(str, match.group(1)) if match else None - - -def extract_json(log_content: str) -> dict | None: - match = re.search(r"\{.*\}", log_content, re.DOTALL) - if match: - return cast(dict, json.loads(match.group(0))) - return None - - -def gen_datetime(dt: datetime | None = None) -> datetime: - """ - Generate a datetime object in UTC timezone. - - If `dt` is None, it will return the current time in UTC. - - If `dt` is provided, it will convert it to UTC timezone. - """ - if dt is None: - return datetime.now(timezone.utc) - return dt.astimezone(timezone.utc) - - -def dict_get_with_warning(d: dict, key: str, default: Any = None) -> Any: - """ - Motivation: - - When handling the repsonse from the LLM, we may use dict get to get the value. - - the function prevent falling into default value **silently**. - - Instead, it will log a warning message. - """ - from rdagent.log import rdagent_logger as logger - - if key not in d: - logger.warning(f"Key {key} not found in {d}") - return default - return d[key] diff --git a/rdagent/log/utils/folder.py b/rdagent/log/utils/folder.py deleted file mode 100644 index 453fa215..00000000 --- a/rdagent/log/utils/folder.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -This module provides some useful functions for working with logger folders. -""" - -import pickle -from datetime import timedelta -from pathlib import Path - -import pandas as pd - -from rdagent.utils.workflow import LoopBase - - -def get_first_session_file_after_duration(log_folder: str | Path, duration: str | pd.Timedelta) -> Path: - log_folder = Path(log_folder) - duration_dt = pd.Timedelta(duration) - # iterate the dump steps in increasing order - files = sorted( - (log_folder / "__session__").glob("*/*_*"), key=lambda f: (int(f.parent.name), int(f.name.split("_")[0])) - ) - fp = None - for fp in files: - with fp.open("rb") as f: - session_obj: LoopBase = pickle.load(f) - timer = session_obj.timer - all_duration = timer.all_duration - remain_time_duration = timer.remain_time() - if all_duration is None or remain_time_duration is None: - msg = "Timer is not configured" - raise ValueError(msg) - time_spent = all_duration - remain_time_duration - if time_spent >= duration_dt: - break - if fp is None: - msg = f"No session file found after duration {duration}" - raise ValueError(msg) - return fp - - -def first_li_si_after_one_time(log_path: Path, hours: int = 12) -> tuple[int, int, str]: - """ - Based on the hours, find the stop loop id and step id (the first step after hours). - Args: - log_path (Path): The path to the log folder (contains many log traces). - hours (int): The number of hours to stat. - Returns: - tuple[int, int, str]: The loop id, step id and function name. - """ - session_path = log_path / "__session__" - max_li = max(int(p.name) for p in session_path.iterdir() if p.is_dir() and p.name.isdigit()) - max_step = max(int(p.name.split("_")[0]) for p in (session_path / str(max_li)).iterdir() if p.is_file()) - rdloop_obj_p = next((session_path / str(max_li)).glob(f"{max_step}_*")) - - rdloop_obj = DataScienceRDLoop.load(rdloop_obj_p) - loop_trace = rdloop_obj.loop_trace - si2fn = rdloop_obj.steps - - duration = timedelta(seconds=0) - for li, lts in loop_trace.items(): - for lt in lts: - si = lt.step_idx - duration += lt.end - lt.start - if duration > timedelta(hours=hours): - return li, si, si2fn[si] - - -if __name__ == "__main__": - from rdagent.app.data_science.loop import DataScienceRDLoop - - f = get_first_session_file_after_duration("", pd.Timedelta("12h")) - - with f.open("rb") as f: - session_obj: LoopBase = pickle.load(f) - loop_trace = session_obj.loop_trace - last_loop = loop_trace[max(loop_trace.keys())] - last_step = last_loop[-1] - session_obj.steps[last_step.step_idx] diff --git a/setup_predix_eurusd.sh b/setup_predix_eurusd.sh new file mode 100755 index 00000000..321a8068 --- /dev/null +++ b/setup_predix_eurusd.sh @@ -0,0 +1,301 @@ +#!/bin/bash +# ============================================================================= +# setup_predix_eurusd.sh +# Richtet Predix fรผr EURUSD 15min Trading ein +# Ausfรผhren: bash setup_predix_eurusd.sh +# ============================================================================= + +set -e # Abbruch bei Fehler + +PREDIX_DIR="$HOME/Predix" +CSV_SOURCE="$HOME/Downloads/eurusd_data.csv" +DATA_DIR="$PREDIX_DIR/git_ignore_folder/eurusd_data" +QLIB_DIR="$HOME/.qlib/qlib_data/eurusd_data" + +echo "========================================" +echo " Predix EURUSD Setup" +echo "========================================" + +# โ”€โ”€โ”€ 1. Prรผfen ob alles da ist โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "" +echo "[1/7] Prรผfe Voraussetzungen..." + +if [ ! -d "$PREDIX_DIR" ]; then + echo "FEHLER: $PREDIX_DIR nicht gefunden!" + exit 1 +fi + +if [ ! -f "$CSV_SOURCE" ]; then + echo "FEHLER: $CSV_SOURCE nicht gefunden!" + echo "Bitte eurusd_data.csv in ~/Downloads/ legen" + exit 1 +fi + +echo "โœ“ Predix gefunden: $PREDIX_DIR" +echo "โœ“ CSV gefunden: $CSV_SOURCE" + +# โ”€โ”€โ”€ 2. Ordner anlegen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "" +echo "[2/7] Erstelle Ordnerstruktur..." + +mkdir -p "$DATA_DIR" +mkdir -p "$QLIB_DIR/calendars" +mkdir -p "$QLIB_DIR/instruments" +mkdir -p "$QLIB_DIR/features/eurusd" +mkdir -p "$PREDIX_DIR/git_ignore_folder/log" + +cp "$CSV_SOURCE" "$DATA_DIR/eurusd_data.csv" +echo "โœ“ CSV kopiert nach $DATA_DIR" + +# โ”€โ”€โ”€ 3. CSV โ†’ Qlib Format konvertieren โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "" +echo "[3/7] Konvertiere CSV zu Qlib-Format..." + +python3 << 'PYEOF' +import pandas as pd +import numpy as np +from pathlib import Path +import os + +QLIB_DIR = Path(os.path.expanduser("~/.qlib/qlib_data/eurusd_data")) +CSV_PATH = Path(os.path.expanduser("~/Downloads/eurusd_data.csv")) + +# Laden + sortieren +df = pd.read_csv(CSV_PATH, parse_dates=["datetime"]) +df = df.sort_values("datetime").reset_index(drop=True) +df.columns = [c.lower() for c in df.columns] + +print(f" Rows: {len(df):,} | Range: {df['datetime'].min().date()} -> {df['datetime'].max().date()}") + +# โ”€โ”€ Kalender (alle 15min Timestamps) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +cal = df["datetime"].dt.strftime("%Y-%m-%d %H:%M:%S") +cal_path = QLIB_DIR / "calendars" / "15min.txt" +cal.to_csv(cal_path, index=False, header=False) +print(f" โœ“ Kalender: {len(cal)} Eintrรคge -> {cal_path}") + +# โ”€โ”€ Instruments (nur EURUSD) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +inst_path = QLIB_DIR / "instruments" / "all.txt" +start = df["datetime"].min().strftime("%Y-%m-%d") +end = df["datetime"].max().strftime("%Y-%m-%d") +with open(inst_path, "w") as f: + f.write(f"EURUSD\t{start}\t{end}\n") +print(f" โœ“ Instruments -> {inst_path}") + +# โ”€โ”€ Features (Qlib binary format via CSV) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +feat_dir = QLIB_DIR / "features" / "eurusd" +feat_dir.mkdir(parents=True, exist_ok=True) + +# Qlib erwartet: $open, $high, $low, $close, $volume +for col in ["open", "high", "low", "close", "volume"]: + out = feat_dir / f"{col}.day.bin" + # Qlib binary: float32 array + arr = df[col].astype("float32").values + arr.tofile(str(out).replace(".day.bin", f"_15min.bin")) + +# Auch als einfache CSV fรผr direkten Zugriff +df.to_csv(QLIB_DIR / "eurusd_15min.csv", index=False) +print(f" โœ“ Features + CSV -> {feat_dir}") + +# โ”€โ”€ Returns + technische Features vorberechnen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +def ema(s, p): return s.ewm(span=p, adjust=False).mean() +def rsi(c, p=14): + d = c.diff() + g = d.clip(lower=0).ewm(span=p, adjust=False).mean() + l = (-d.clip(upper=0)).ewm(span=p, adjust=False).mean() + return 100 - 100/(1 + g/(l+1e-9)) + +feat = pd.DataFrame() +feat["datetime"] = df["datetime"] +feat["close"] = df["close"] +for n in [1,4,8,16,96]: + feat[f"ret_{n}"] = df["close"].pct_change(n) +feat["rsi_14"] = rsi(df["close"], 14) +feat["macd_hist"] = ema(df["close"],12) - ema(df["close"],26) +feat["hour"] = df["datetime"].dt.hour +feat["is_london"] = feat["hour"].isin([8,9,10,11]).astype(int) +feat["is_ny"] = feat["hour"].isin([13,14,15,16]).astype(int) +feat["adx_proxy"] = df["close"].rolling(14).std() / df["close"].rolling(96).std() + +feat.dropna(inplace=True) +feat.to_csv(QLIB_DIR / "eurusd_features.csv", index=False) +print(f" โœ“ Features CSV: {len(feat):,} Zeilen, {len(feat.columns)} Spalten") +print(" Fertig!") +PYEOF + +echo "โœ“ Qlib-Daten konvertiert" + +# โ”€โ”€โ”€ 4. .env updaten โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "" +echo "[4/7] Update .env..." + +ENV_FILE="$PREDIX_DIR/.env" + +# Backup +cp "$ENV_FILE" "$ENV_FILE.backup_$(date +%Y%m%d_%H%M%S)" + +# QLIB_DATA_DIR auf EURUSD umbiegen +sed -i "s|QLIB_DATA_DIR=.*|QLIB_DATA_DIR=$QLIB_DIR|" "$ENV_FILE" + +# LOG_PATH korrigieren (war /home/nico/RD-Agent-Local/log) +sed -i "s|LOG_PATH=.*|LOG_PATH=$PREDIX_DIR/git_ignore_folder/log|" "$ENV_FILE" + +# EURUSD-spezifische Vars hinzufรผgen (falls noch nicht da) +grep -q "EURUSD_DATA_PATH" "$ENV_FILE" || cat >> "$ENV_FILE" << 'ENVEOF' + +# ---------- EURUSD ---------- +EURUSD_DATA_PATH=/home/nico/.qlib/qlib_data/eurusd_data/eurusd_15min.csv +QLIB_FREQ=15min +QLIB_MARKET=eurusd +BACKTEST_START_TIME=2024-08-09 +BACKTEST_END_TIME=2026-03-20 +COST_RATE=0.00015 +ENVEOF + +echo "โœ“ .env aktualisiert (Backup erstellt)" + +# โ”€โ”€โ”€ 5. Prompts fรผr EURUSD anpassen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "" +echo "[5/7] Passe Qlib-Prompts fรผr EURUSD an..." + +PROMPT_FILE="$PREDIX_DIR/rdagent/app/qlib_rd_loop/prompts.yaml" +cp "$PROMPT_FILE" "${PROMPT_FILE}.backup_$(date +%Y%m%d_%H%M%S)" + +cat > "$PROMPT_FILE" << 'YAMLEOF' +hypothesis_generation: + system: |- + You are an expert quantitative researcher specialized in FX (foreign exchange) trading, + specifically EURUSD intraday strategies on 15-minute bars. + + EURUSD domain knowledge you must apply: + - London session (08:00-12:00 UTC): highest volatility, trending behavior โ€” favor momentum strategies + - NY session (13:00-17:00 UTC): second volatility peak, also trending + - Asian session (00:00-07:00 UTC): low volatility, mean-reverting behavior + - London/NY overlap (13:00-17:00 UTC): strongest directional moves of the day + - Weekend gap risk: avoid holding positions after Friday 20:00 UTC + - Spread cost: ~1.5 bps per trade โ€” strategies must minimize unnecessary entries + - EURUSD is mean-reverting on short windows (<1h), trending on longer (>4h) + - Key macro drivers: ECB/Fed rate decisions, NFP (first Friday of month), CPI releases + + Available model types you can propose: + - TimeSeries: LSTM, GRU, TCN (Temporal Convolutional Network), Transformer, PatchTST + - Tabular: XGBoost, LightGBM, RandomForest (on engineered features) + - Hybrid: CNN+LSTM, XGBoost+LSTM ensemble + - Statistical: Regime-switching (HMM), Kalman filter + + Available features in the dataset: + - OHLCV: open, high, low, close, volume (15min bars) + - Returns: ret_1, ret_4, ret_8, ret_16, ret_96 + - Technical: rsi_14, macd_hist, adx_14, atr_14, bb_pct, stoch_k, cci_14 + - Volatility: vol_real_4, vol_real_16, vol_ratio, zscore_ret_96 + - Time/Session: hour, is_london, is_ny, is_overlap, hour_sin, hour_cos + - Lags: rsi_14_lag1-8, macd_hist_lag1-8, bb_pct_lag1-8 + + Your hypothesis must: + 1. Specify which session(s) the strategy targets + 2. Name which model type to use and why it fits EURUSD + 3. Include a session filter (is_london / is_ny) + 4. Include a spread filter (only trade when expected |return| > 0.0003) + 5. Specify target: classification (fwd_sign_4) or regression (fwd_ret_4) + + Please ensure your response is in JSON format: + { + "hypothesis": "A clear and concise trading hypothesis for EURUSD 15min.", + "reason": "Detailed explanation including session, model choice, and expected edge.", + "model_type": "One of: TimeSeries / Tabular / XGBoost", + "target_session": "london / ny / asian / all", + "expected_arr_range": "e.g. 8-12%" + } + + user: |- + Previously tried approaches and their results: + {{ factor_descriptions }} + + Additional context: + {{ report_content }} + + Generate a NEW hypothesis that is meaningfully different from what has been tried. + Focus on approaches that have NOT been tested yet. + Target: beat current best ARR of 9.62%. +YAMLEOF + +echo "โœ“ prompts.yaml aktualisiert (Backup erstellt)" + +# โ”€โ”€โ”€ 6. Model Coder Prompt erweitern โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "" +echo "[6/7] Erweitere Model Coder Prompts..." + +MODEL_PROMPT="$PREDIX_DIR/rdagent/components/coder/model_coder/prompts.yaml" +cp "$MODEL_PROMPT" "${MODEL_PROMPT}.backup_$(date +%Y%m%d_%H%M%S)" + +# EURUSD session filter als Kommentar in den evolving_strategy Block injizieren +python3 << 'PYEOF' +import re +from pathlib import Path + +path = Path("/home/nico/Predix/rdagent/components/coder/model_coder/prompts.yaml") +content = path.read_text() + +eurusd_note = """ + EURUSD-specific rules (ALWAYS apply these in generated code): + 1. Session filter: use is_london and is_ny columns โ€” weight/filter signals to active sessions + 2. Spread filter: only generate signal when abs(predicted_return) > 0.0003 + 3. ADX regime: if adx_proxy > 1.2 use trend model; if adx_proxy < 0.8 use mean-reversion + 4. Weekend filter: zero out signals when dayofweek==4 and hour>=20 + 5. Max trade frequency: target <15 trades per day (avoid spread cost death) + 6. Supported model_type values: "Tabular", "TimeSeries", "XGBoost" +""" + +# Inject after the scenario line in evolving_strategy_model_coder +content = content.replace( + " Your code is expected to align the scenario in any form", + eurusd_note + "\n Your code is expected to align the scenario in any form" +) +path.write_text(content) +print(" โœ“ Model coder prompt erweitert") +PYEOF + +echo "โœ“ Model coder Prompt angepasst" + +# โ”€โ”€โ”€ 7. Git Commits โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo "" +echo "[7/7] Git Commits..." + +cd "$PREDIX_DIR" + +git add rdagent/app/qlib_rd_loop/prompts.yaml +git commit -m "feat: EURUSD 15min prompts - session filter, FX domain knowledge + +- Add London/NY/Asian session awareness to hypothesis generation +- Add model type suggestions: LSTM, GRU, TCN, Transformer, XGBoost, LightGBM +- Add spread filter (1.5 bps) and ADX regime detection +- Target: beat current best ARR of 9.62%" + +git add rdagent/components/coder/model_coder/prompts.yaml +git commit -m "feat: inject EURUSD trading rules into model coder + +- Session filter (is_london, is_ny) +- Spread filter (|return| > 0.0003) +- Weekend position close +- Max trade frequency guidance" + +git add .env 2>/dev/null || true +echo " (Note: .env nicht committed - enthรคlt API Keys)" + +echo "" +echo "========================================" +echo " Setup abgeschlossen!" +echo "========================================" +echo "" +echo "Nรคchste Schritte:" +echo "" +echo " 1. Starten:" +echo " cd ~/Predix && rdagent fin_quant" +echo "" +echo " 2. Dashboard (in zweitem Terminal):" +echo " cd ~/Predix && rdagent server_ui --port 19899" +echo " โ†’ http://localhost:19899" +echo "" +echo " 3. Logs:" +echo " tail -f $PREDIX_DIR/git_ignore_folder/log/*.log" +echo "" +echo "Backup-Dateien (.backup_*) kรถnnen nach erfolgreichem Test gelรถscht werden."