3 Commits

Author SHA1 Message Date
a41cd705b4 Prefer SQLite signals in stock explain view 2026-03-16 02:22:59 +08:00
564c92c0c8 确认PokieTicker新闻库数据源 2026-03-16 02:19:25 +08:00
78f133617f Add run-scoped skill and prompt asset management 2026-03-16 00:04:04 +08:00
196 changed files with 7794 additions and 1114 deletions

View File

@@ -11,9 +11,7 @@ from agentscope.message import Msg
from ..config.constants import ANALYST_TYPES from ..config.constants import ANALYST_TYPES
from ..utils.progress import progress from ..utils.progress import progress
from .prompt_loader import PromptLoader from .prompt_factory import build_agent_system_prompt, clear_prompt_factory_cache
_prompt_loader = PromptLoader()
class AnalystAgent(ReActAgent): class AnalystAgent(ReActAgent):
@@ -50,14 +48,19 @@ class AnalystAgent(ReActAgent):
f"Must be one of: {list(ANALYST_TYPES.keys())}", f"Must be one of: {list(ANALYST_TYPES.keys())}",
) )
self.analyst_type_key = analyst_type object.__setattr__(self, "analyst_type_key", analyst_type)
self.analyst_persona = ANALYST_TYPES[analyst_type]["display_name"] object.__setattr__(
self,
"analyst_persona",
ANALYST_TYPES[analyst_type]["display_name"],
)
if agent_id is None: if agent_id is None:
agent_id = analyst_type agent_id = analyst_type
object.__setattr__(self, "agent_id", agent_id)
self.config = config or {} object.__setattr__(self, "config", config or {})
object.__setattr__(self, "toolkit", toolkit)
sys_prompt = self._load_system_prompt() sys_prompt = self._load_system_prompt()
kwargs = { kwargs = {
@@ -77,27 +80,11 @@ class AnalystAgent(ReActAgent):
def _load_system_prompt(self) -> str: def _load_system_prompt(self) -> str:
"""Load system prompt for analyst""" """Load system prompt for analyst"""
personas_config = _prompt_loader.load_yaml_config( return build_agent_system_prompt(
"analyst", agent_id=self.agent_id,
"personas", config_name=self.config.get("config_name", "default"),
) toolkit=self.toolkit,
persona = personas_config.get(self.analyst_type_key, {}) analyst_type=self.analyst_type_key,
# Get focus items and format as bullet points
focus_items = persona.get("focus", [])
focus_text = "\n".join(f"- {item}" for item in focus_items)
# Get description
description = persona.get("description", "").strip()
return _prompt_loader.load_prompt(
"analyst",
"system",
variables={
"analyst_type": self.analyst_persona,
"focus": focus_text,
"description": description,
},
) )
async def reply(self, x: Msg = None) -> Msg: async def reply(self, x: Msg = None) -> Msg:
@@ -131,3 +118,23 @@ class AnalystAgent(ReActAgent):
) )
return result return result
def reload_runtime_assets(self, active_skill_dirs: Optional[list] = None) -> None:
"""Reload toolkit and system prompt from current run assets."""
from .toolkit_factory import create_agent_toolkit
clear_prompt_factory_cache()
self.toolkit = create_agent_toolkit(
self.agent_id,
self.config.get("config_name", "default"),
active_skill_dirs=active_skill_dirs,
)
self._apply_runtime_sys_prompt(self._load_system_prompt())
def _apply_runtime_sys_prompt(self, sys_prompt: str) -> None:
"""Update the prompt used by future turns and the cached system msg."""
self._sys_prompt = sys_prompt
for msg, _marks in self.memory.content:
if getattr(msg, "role", None) == "system":
msg.content = sys_prompt
break

View File

@@ -12,9 +12,7 @@ from agentscope.message import Msg, TextBlock
from agentscope.tool import Toolkit, ToolResponse from agentscope.tool import Toolkit, ToolResponse
from ..utils.progress import progress from ..utils.progress import progress
from .prompt_loader import PromptLoader from .prompt_factory import build_agent_system_prompt, clear_prompt_factory_cache
_prompt_loader = PromptLoader()
class PMAgent(ReActAgent): class PMAgent(ReActAgent):
@@ -36,24 +34,52 @@ class PMAgent(ReActAgent):
margin_requirement: float = 0.25, margin_requirement: float = 0.25,
config: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None,
long_term_memory: Optional[LongTermMemoryBase] = None, long_term_memory: Optional[LongTermMemoryBase] = None,
toolkit_factory: Any = None,
toolkit_factory_kwargs: Optional[Dict[str, Any]] = None,
toolkit: Optional[Toolkit] = None,
): ):
self.config = config or {} object.__setattr__(self, "config", config or {})
# Portfolio state # Portfolio state
self.portfolio = { object.__setattr__(
self,
"portfolio",
{
"cash": initial_cash, "cash": initial_cash,
"positions": {}, "positions": {},
"margin_used": 0.0, "margin_used": 0.0,
"margin_requirement": margin_requirement, "margin_requirement": margin_requirement,
} },
)
# Decisions made in current cycle # Decisions made in current cycle
self._decisions: Dict[str, Dict] = {} object.__setattr__(self, "_decisions", {})
toolkit_factory_kwargs = toolkit_factory_kwargs or {}
object.__setattr__(self, "_toolkit_factory", toolkit_factory)
object.__setattr__(
self,
"_toolkit_factory_kwargs",
toolkit_factory_kwargs,
)
# Create toolkit # Create toolkit after local state is ready so bound tool methods can be registered.
if toolkit is None:
if toolkit_factory is not None:
toolkit = toolkit_factory(
name,
self.config.get("config_name", "default"),
owner=self,
**toolkit_factory_kwargs,
)
else:
toolkit = self._create_toolkit() toolkit = self._create_toolkit()
object.__setattr__(self, "toolkit", toolkit)
sys_prompt = _prompt_loader.load_prompt("portfolio_manager", "system") sys_prompt = build_agent_system_prompt(
agent_id=name,
config_name=self.config.get("config_name", "default"),
toolkit=self.toolkit,
)
kwargs = { kwargs = {
"name": name, "name": name,
@@ -186,3 +212,71 @@ class PMAgent(ReActAgent):
def update_portfolio(self, portfolio: Dict[str, Any]): def update_portfolio(self, portfolio: Dict[str, Any]):
"""Update portfolio after external execution""" """Update portfolio after external execution"""
self.portfolio.update(portfolio) self.portfolio.update(portfolio)
def _has_open_positions(self) -> bool:
"""Return whether the current portfolio still has non-zero positions."""
for position in self.portfolio.get("positions", {}).values():
if position.get("long", 0) or position.get("short", 0):
return True
return False
def can_apply_initial_cash(self) -> bool:
"""Only allow cash rebasing before any positions or margin exist."""
return (
not self._has_open_positions()
and float(self.portfolio.get("margin_used", 0.0) or 0.0) == 0.0
)
def apply_runtime_portfolio_config(
self,
*,
margin_requirement: Optional[float] = None,
initial_cash: Optional[float] = None,
) -> Dict[str, bool]:
"""Apply safe run-time portfolio config updates."""
result = {
"margin_requirement": False,
"initial_cash": False,
}
if margin_requirement is not None:
self.portfolio["margin_requirement"] = float(margin_requirement)
result["margin_requirement"] = True
if initial_cash is not None and self.can_apply_initial_cash():
self.portfolio["cash"] = float(initial_cash)
result["initial_cash"] = True
return result
def reload_runtime_assets(self, active_skill_dirs: Optional[list] = None) -> None:
"""Reload toolkit and system prompt from current run assets."""
from .toolkit_factory import create_agent_toolkit
clear_prompt_factory_cache()
toolkit_factory = self._toolkit_factory or create_agent_toolkit
toolkit_kwargs = dict(self._toolkit_factory_kwargs)
if active_skill_dirs is not None:
toolkit_kwargs["active_skill_dirs"] = active_skill_dirs
self.toolkit = toolkit_factory(
self.name,
self.config.get("config_name", "default"),
owner=self,
**toolkit_kwargs,
)
self._apply_runtime_sys_prompt(
build_agent_system_prompt(
agent_id=self.name,
config_name=self.config.get("config_name", "default"),
toolkit=self.toolkit,
),
)
def _apply_runtime_sys_prompt(self, sys_prompt: str) -> None:
"""Update the prompt used by future turns and the cached system msg."""
self._sys_prompt = sys_prompt
for msg, _marks in self.memory.content:
if getattr(msg, "role", None) == "system":
msg.content = sys_prompt
break

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""Assemble system prompts from base prompts, run assets, and toolkit context."""
from pathlib import Path
from typing import Any, Optional
from backend.config.bootstrap_config import get_bootstrap_config_for_run
from .prompt_loader import PromptLoader
from .skills_manager import SkillsManager
_prompt_loader = PromptLoader()
def _read_file_if_exists(path: Path) -> str:
if not path.exists() or not path.is_file():
return ""
return path.read_text(encoding="utf-8").strip()
def _append_section(parts: list[str], title: str, content: str) -> None:
content = content.strip()
if content:
parts.append(f"## {title}\n{content}")
def build_agent_system_prompt(
agent_id: str,
config_name: str,
toolkit: Any,
analyst_type: Optional[str] = None,
) -> str:
"""Build the final system prompt for an agent."""
sections: list[str] = []
if analyst_type:
personas_config = _prompt_loader.load_yaml_config(
"analyst",
"personas",
)
persona = personas_config.get(analyst_type, {})
focus_text = "\n".join(
f"- {item}" for item in persona.get("focus", [])
)
description = persona.get("description", "").strip()
base_prompt = _prompt_loader.load_prompt(
"analyst",
"system",
variables={
"analyst_type": persona.get("name", analyst_type),
"focus": focus_text,
"description": description,
},
)
elif agent_id == "portfolio_manager":
base_prompt = _prompt_loader.load_prompt(
"portfolio_manager",
"system",
)
elif agent_id == "risk_manager":
base_prompt = _prompt_loader.load_prompt(
"risk_manager",
"system",
)
else:
raise ValueError(f"Unsupported agent prompt build for: {agent_id}")
sections.append(base_prompt.strip())
skills_manager = SkillsManager()
asset_dir = skills_manager.get_agent_asset_dir(config_name, agent_id)
asset_dir.mkdir(parents=True, exist_ok=True)
bootstrap_config = get_bootstrap_config_for_run(
skills_manager.project_root,
config_name,
)
_append_section(
sections,
"Bootstrap",
bootstrap_config.prompt_body,
)
_append_section(
sections,
"Role",
_read_file_if_exists(asset_dir / "ROLE.md"),
)
_append_section(
sections,
"Style",
_read_file_if_exists(asset_dir / "STYLE.md"),
)
_append_section(
sections,
"Policy",
_read_file_if_exists(asset_dir / "POLICY.md"),
)
skill_prompt = toolkit.get_agent_skill_prompt()
if skill_prompt:
_append_section(sections, "Skills", str(skill_prompt))
activated_notes = toolkit.get_activated_notes()
if activated_notes:
_append_section(sections, "Tool Usage Notes", str(activated_notes))
return "\n\n".join(section for section in sections if section.strip())
def clear_prompt_factory_cache() -> None:
"""Clear cached prompt and YAML templates before hot reload."""
_prompt_loader.clear_cache()

View File

@@ -8,12 +8,6 @@ fundamentals_analyst:
- "管理层质量和公司治理" - "管理层质量和公司治理"
- "行业地位和市场份额" - "行业地位和市场份额"
- "长期投资价值评估" - "长期投资价值评估"
tools:
- "analyze_profitability"
- "analyze_growth"
- "analyze_financial_health"
- "analyze_valuation_ratios"
- "analyze_efficiency_ratios"
description: | description: |
作为基本面分析师,你专注于: 作为基本面分析师,你专注于:
- 公司财务健康状况和盈利能力 - 公司财务健康状况和盈利能力
@@ -39,11 +33,6 @@ technical_analyst:
- 支撑/阻力位和关键价格点 - 支撑/阻力位和关键价格点
- 中短期交易机会 - 中短期交易机会
你倾向于选择能够捕捉价格动态和市场趋势的工具,更偏好技术分析类工具。 你倾向于选择能够捕捉价格动态和市场趋势的工具,更偏好技术分析类工具。
tools:
- "analyze_trend_following"
- "analyze_momentum"
- "analyze_mean_reversion"
- "analyze_volatility"
sentiment_analyst: sentiment_analyst:
name: "情绪分析师" name: "情绪分析师"
@@ -61,10 +50,6 @@ sentiment_analyst:
- 投资者恐慌和贪婪情绪 - 投资者恐慌和贪婪情绪
- 市场预期和心理因素 - 市场预期和心理因素
你倾向于选择能够反映市场情绪和投资者行为的工具,更偏好情绪和行为类工具。 你倾向于选择能够反映市场情绪和投资者行为的工具,更偏好情绪和行为类工具。
tools:
- "analyze_news_sentiment"
- "analyze_insider_trading"
valuation_analyst: valuation_analyst:
name: "估值分析师" name: "估值分析师"
focus: focus:
@@ -81,12 +66,6 @@ valuation_analyst:
- 相对估值和绝对估值 - 相对估值和绝对估值
- 投资安全边际评估 - 投资安全边际评估
你倾向于选择能够准确计算公司价值的工具,更偏好估值模型和基本面工具。 你倾向于选择能够准确计算公司价值的工具,更偏好估值模型和基本面工具。
tools:
- "dcf_valuation_analysis"
- "owner_earnings_valuation_analysis"
- "ev_ebitda_valuation_analysis"
- "residual_income_valuation_analysis"
comprehensive_analyst: comprehensive_analyst:
name: "综合分析师" name: "综合分析师"
focus: focus:
@@ -103,15 +82,3 @@ comprehensive_analyst:
- 提供全面的投资建议 - 提供全面的投资建议
- 适应不同市场环境 - 适应不同市场环境
你会根据具体情况灵活选择各类工具,追求分析的全面性和准确性。 你会根据具体情况灵活选择各类工具,追求分析的全面性和准确性。
tools:
- "analyze_profitability"
- "analyze_growth"
- "analyze_financial_health"
- "analyze_valuation_ratios"
- "analyze_efficiency_ratios"
- "analyze_trend_following"
- "analyze_momentum"
- "analyze_mean_reversion"
- "analyze_volatility"
- "analyze_news_sentiment"
- "analyze_insider_trading"

View File

@@ -8,6 +8,8 @@
5. 基于市场条件建议仓位限制 5. 基于市场条件建议仓位限制
你的决策流程: 你的决策流程:
1. 优先使用可用的风险工具量化集中度、波动率和保证金压力
2. 结合工具结果与当前市场上下文做判断
3. 生成可操作的风险警告和仓位限制建议 3. 生成可操作的风险警告和仓位限制建议
4. 为你的风险评估提供清晰的理由 4. 为你的风险评估提供清晰的理由

View File

@@ -11,9 +11,7 @@ from agentscope.message import Msg
from agentscope.tool import Toolkit from agentscope.tool import Toolkit
from ..utils.progress import progress from ..utils.progress import progress
from .prompt_loader import PromptLoader from .prompt_factory import build_agent_system_prompt, clear_prompt_factory_cache
_prompt_loader = PromptLoader()
class RiskAgent(ReActAgent): class RiskAgent(ReActAgent):
@@ -29,6 +27,7 @@ class RiskAgent(ReActAgent):
name: str = "risk_manager", name: str = "risk_manager",
config: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None,
long_term_memory: Optional[LongTermMemoryBase] = None, long_term_memory: Optional[LongTermMemoryBase] = None,
toolkit: Optional[Toolkit] = None,
): ):
""" """
Initialize Risk Manager Agent Initialize Risk Manager Agent
@@ -40,13 +39,15 @@ class RiskAgent(ReActAgent):
config: Configuration dictionary config: Configuration dictionary
long_term_memory: Optional ReMeTaskLongTermMemory instance long_term_memory: Optional ReMeTaskLongTermMemory instance
""" """
self.config = config or {} object.__setattr__(self, "config", config or {})
object.__setattr__(self, "agent_id", name)
if toolkit is None:
toolkit = Toolkit()
object.__setattr__(self, "toolkit", toolkit)
sys_prompt = self._load_system_prompt() sys_prompt = self._load_system_prompt()
# Create dedicated toolkit for this agent
toolkit = Toolkit()
kwargs = { kwargs = {
"name": name, "name": name,
"sys_prompt": sys_prompt, "sys_prompt": sys_prompt,
@@ -64,9 +65,10 @@ class RiskAgent(ReActAgent):
def _load_system_prompt(self) -> str: def _load_system_prompt(self) -> str:
"""Load system prompt for risk manager""" """Load system prompt for risk manager"""
return _prompt_loader.load_prompt( return build_agent_system_prompt(
"risk_manager", agent_id=self.agent_id,
"system", config_name=self.config.get("config_name", "default"),
toolkit=self.toolkit,
) )
async def reply(self, x: Msg = None) -> Msg: async def reply(self, x: Msg = None) -> Msg:
@@ -86,3 +88,23 @@ class RiskAgent(ReActAgent):
progress.update_status(self.name, None, "Risk assessment completed") progress.update_status(self.name, None, "Risk assessment completed")
return result return result
def reload_runtime_assets(self, active_skill_dirs: Optional[list] = None) -> None:
"""Reload toolkit and system prompt from current run assets."""
from .toolkit_factory import create_agent_toolkit
clear_prompt_factory_cache()
self.toolkit = create_agent_toolkit(
self.agent_id,
self.config.get("config_name", "default"),
active_skill_dirs=active_skill_dirs,
)
self._apply_runtime_sys_prompt(self._load_system_prompt())
def _apply_runtime_sys_prompt(self, sys_prompt: str) -> None:
"""Update the prompt used by future turns and the cached system msg."""
self._sys_prompt = sys_prompt
for msg, _marks in self.memory.content:
if getattr(msg, "role", None) == "system":
msg.content = sys_prompt
break

View File

@@ -0,0 +1,207 @@
# -*- coding: utf-8 -*-
"""Manage builtin/customized/active skill directories for each run."""
from pathlib import Path
import shutil
from typing import Dict, Iterable, List
import yaml
from backend.config.bootstrap_config import get_bootstrap_config_for_run
class SkillsManager:
"""Sync named skills into a run-scoped active skills workspace."""
def __init__(self, project_root: Path | None = None):
self.project_root = (
project_root or Path(__file__).resolve().parents[2]
)
self.builtin_root = self.project_root / "backend" / "skills" / "builtin"
self.customized_root = (
self.project_root / "backend" / "skills" / "customized"
)
self.runs_root = self.project_root / "runs"
def get_active_root(self, config_name: str) -> Path:
return self.runs_root / config_name / "skills" / "active"
def get_activation_manifest_path(self, config_name: str) -> Path:
return self.runs_root / config_name / "skills" / "activation.yaml"
def get_agent_asset_dir(self, config_name: str, agent_id: str) -> Path:
return self.runs_root / config_name / "agents" / agent_id
def ensure_activation_manifest(self, config_name: str) -> Path:
manifest_path = self.get_activation_manifest_path(config_name)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
if not manifest_path.exists():
manifest_path.write_text(
"global_enabled_skills: []\n"
"global_disabled_skills: []\n"
"agent_enabled_skills: {}\n"
"agent_disabled_skills: {}\n",
encoding="utf-8",
)
return manifest_path
def load_activation_manifest(self, config_name: str) -> Dict[str, object]:
manifest_path = self.ensure_activation_manifest(config_name)
with open(manifest_path, "r", encoding="utf-8") as file:
parsed = yaml.safe_load(file) or {}
return parsed if isinstance(parsed, dict) else {}
def _resolve_source_dir(self, skill_name: str) -> Path:
customized_dir = self.customized_root / skill_name
if customized_dir.exists():
return customized_dir
builtin_dir = self.builtin_root / skill_name
if builtin_dir.exists():
return builtin_dir
raise FileNotFoundError(f"Unknown skill: {skill_name}")
def _persist_runtime_edits(
self,
config_name: str,
skill_name: str,
active_dir: Path,
) -> None:
"""
Persist run-time edits from active skills into customized skills.
This keeps active skill experiments from being lost on the next reload
while still allowing the active directory to be re-synced cleanly.
"""
if not active_dir.exists():
return
source_dir = self._resolve_source_dir(skill_name)
if active_dir.resolve() == source_dir.resolve():
return
if not self._directories_match(active_dir, source_dir):
customized_dir = self.customized_root / skill_name
customized_dir.parent.mkdir(parents=True, exist_ok=True)
if customized_dir.exists():
shutil.rmtree(customized_dir)
shutil.copytree(active_dir, customized_dir)
@staticmethod
def _directories_match(left: Path, right: Path) -> bool:
"""Compare two directory trees by file contents."""
if not left.exists() or not right.exists():
return False
left_items = sorted(
path.relative_to(left)
for path in left.rglob("*")
)
right_items = sorted(
path.relative_to(right)
for path in right.rglob("*")
)
if left_items != right_items:
return False
for relative_path in left_items:
left_path = left / relative_path
right_path = right / relative_path
if left_path.is_dir() != right_path.is_dir():
return False
if left_path.is_file():
if left_path.read_bytes() != right_path.read_bytes():
return False
return True
def resolve_agent_skill_names(
self,
config_name: str,
agent_id: str,
default_skills: Iterable[str],
) -> List[str]:
"""Resolve final skill names after bootstrap and activation overlays."""
bootstrap = get_bootstrap_config_for_run(self.project_root, config_name)
override = bootstrap.agent_override(agent_id)
skills = list(override.get("skills", list(default_skills)))
manifest = self.load_activation_manifest(config_name)
for skill_name in manifest.get("global_enabled_skills", []):
if skill_name not in skills:
skills.append(skill_name)
for skill_name in manifest.get("agent_enabled_skills", {}).get(agent_id, []):
if skill_name not in skills:
skills.append(skill_name)
disabled = set(manifest.get("global_disabled_skills", []))
disabled.update(
manifest.get("agent_disabled_skills", {}).get(agent_id, []),
)
return [skill for skill in skills if skill not in disabled]
def sync_active_skills(
self,
config_name: str,
skill_names: Iterable[str],
) -> List[Path]:
"""Sync selected skills into the run workspace and return their paths."""
active_root = self.get_active_root(config_name)
active_root.mkdir(parents=True, exist_ok=True)
synced_paths: List[Path] = []
wanted = set(skill_names)
for existing in active_root.iterdir():
if existing.is_dir() and existing.name not in wanted:
self._persist_runtime_edits(
config_name=config_name,
skill_name=existing.name,
active_dir=existing,
)
shutil.rmtree(existing)
for skill_name in skill_names:
source_dir = self._resolve_source_dir(skill_name)
target_dir = active_root / skill_name
if target_dir.exists():
self._persist_runtime_edits(
config_name=config_name,
skill_name=skill_name,
active_dir=target_dir,
)
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
synced_paths.append(target_dir)
return synced_paths
def prepare_active_skills(
self,
config_name: str,
agent_defaults: Dict[str, Iterable[str]],
) -> Dict[str, List[Path]]:
"""Resolve all agent skills, sync the union once, and map paths per agent."""
resolved: Dict[str, List[str]] = {}
union: List[str] = []
for agent_id, default_skills in agent_defaults.items():
resolved_skills = self.resolve_agent_skill_names(
config_name=config_name,
agent_id=agent_id,
default_skills=default_skills,
)
resolved[agent_id] = resolved_skills
for skill_name in resolved_skills:
if skill_name not in union:
union.append(skill_name)
self.sync_active_skills(config_name=config_name, skill_names=union)
active_root = self.get_active_root(config_name)
return {
agent_id: [active_root / skill_name for skill_name in skill_names]
for agent_id, skill_names in resolved.items()
}

View File

@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
"""Toolkit factory following AgentScope's skill + tool group practices."""
from typing import Any, Dict, Iterable
from backend.config.bootstrap_config import get_bootstrap_config_for_run
import yaml
from .skills_manager import SkillsManager
def load_agent_profiles() -> Dict[str, Dict[str, Any]]:
config_path = SkillsManager().project_root / "backend" / "config" / "agent_profiles.yaml"
with open(config_path, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def _register_analysis_tool_groups(toolkit: Any) -> None:
from backend.tools.analysis_tools import TOOL_REGISTRY
tool_groups = {
"fundamentals": {
"description": "Financial health, profitability, growth, and efficiency analysis tools.",
"active": False,
"notes": (
"Use these tools to validate business quality, financial resilience, "
"and earnings durability before making directional conclusions."
),
"tools": [
"analyze_profitability",
"analyze_growth",
"analyze_financial_health",
"analyze_efficiency_ratios",
"analyze_valuation_ratios",
"get_financial_metrics_tool",
],
},
"technical": {
"description": "Trend, momentum, mean reversion, and volatility analysis tools.",
"active": False,
"notes": (
"Use these tools to assess timing, price structure, and risk-reward in "
"the current market regime."
),
"tools": [
"analyze_trend_following",
"analyze_momentum",
"analyze_mean_reversion",
"analyze_volatility",
],
},
"sentiment": {
"description": "News sentiment and insider activity analysis tools.",
"active": False,
"notes": (
"Use these tools to capture short-horizon catalysts, sentiment shifts, "
"and behavioral signals around each ticker."
),
"tools": [
"analyze_news_sentiment",
"analyze_insider_trading",
],
},
"valuation": {
"description": "Intrinsic value and relative valuation analysis tools.",
"active": False,
"notes": (
"Use these tools when the task requires fair value estimation, margin of "
"safety analysis, or valuation scenario comparison."
),
"tools": [
"dcf_valuation_analysis",
"owner_earnings_valuation_analysis",
"ev_ebitda_valuation_analysis",
"residual_income_valuation_analysis",
],
},
}
for group_name, group_config in tool_groups.items():
toolkit.create_tool_group(
group_name=group_name,
description=group_config["description"],
active=group_config["active"],
notes=group_config["notes"],
)
for tool_name in group_config["tools"]:
tool_func = TOOL_REGISTRY.get(tool_name)
if tool_func:
toolkit.register_tool_function(
tool_func,
group_name=group_name,
)
def _register_portfolio_tool_groups(toolkit: Any, pm_agent: Any) -> None:
toolkit.create_tool_group(
group_name="portfolio_ops",
description="Portfolio decision recording tools.",
active=False,
notes=(
"Use portfolio tools only after synthesizing analyst and risk inputs. "
"Record one explicit decision per ticker."
),
)
toolkit.register_tool_function(
pm_agent._make_decision,
group_name="portfolio_ops",
)
def _register_risk_tool_groups(toolkit: Any) -> None:
from backend.tools.risk_tools import (
assess_margin_and_liquidity,
assess_position_concentration,
assess_volatility_exposure,
)
toolkit.create_tool_group(
group_name="risk_ops",
description="Risk diagnostics for concentration, leverage, and volatility.",
active=False,
notes=(
"Use risk tools to quantify concentration, margin pressure, and volatility "
"before writing the final risk memo."
),
)
toolkit.register_tool_function(
assess_position_concentration,
group_name="risk_ops",
)
toolkit.register_tool_function(
assess_margin_and_liquidity,
group_name="risk_ops",
)
toolkit.register_tool_function(
assess_volatility_exposure,
group_name="risk_ops",
)
def create_agent_toolkit(
agent_id: str,
config_name: str,
owner: Any = None,
active_skill_dirs: Iterable[str] | None = None,
) -> Any:
"""Create a Toolkit with agent skills and grouped tools."""
from agentscope.tool import Toolkit
profiles = load_agent_profiles()
profile = profiles.get(agent_id, {})
skills_manager = SkillsManager()
bootstrap_config = get_bootstrap_config_for_run(
skills_manager.project_root,
config_name,
)
override = bootstrap_config.agent_override(agent_id)
active_groups = override.get(
"active_tool_groups",
profile.get("active_tool_groups", []),
)
toolkit = Toolkit(
agent_skill_instruction=(
"<system-info>You have access to project skills. Each skill lives in a "
"directory and is described by SKILL.md. Follow the skill instructions "
"when they are relevant to the current task.</system-info>"
),
agent_skill_template="- {name} (dir: {dir}): {description}",
)
if agent_id.endswith("_analyst"):
_register_analysis_tool_groups(toolkit)
elif agent_id == "portfolio_manager" and owner is not None:
_register_portfolio_tool_groups(toolkit, owner)
elif agent_id == "risk_manager":
_register_risk_tool_groups(toolkit)
if active_skill_dirs is None:
skill_names = skills_manager.resolve_agent_skill_names(
config_name=config_name,
agent_id=agent_id,
default_skills=profile.get("skills", []),
)
active_skill_dirs = [
skills_manager.get_active_root(config_name) / skill_name
for skill_name in skill_names
]
for skill_dir in active_skill_dirs:
toolkit.register_agent_skill(str(skill_dir))
if active_groups:
toolkit.update_tool_groups(group_names=active_groups, active=True)
return toolkit

View File

@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
"""Initialize run-scoped agent workspace assets."""
from pathlib import Path
from typing import Dict, Iterable, Optional
from .skills_manager import SkillsManager
class WorkspaceManager:
"""Create and maintain run-level prompt asset files for each agent."""
def __init__(self, project_root: Optional[Path] = None):
self.skills_manager = SkillsManager(project_root=project_root)
self.project_root = self.skills_manager.project_root
def get_run_dir(self, config_name: str) -> Path:
return self.project_root / "runs" / config_name
def ensure_run_workspace(self, config_name: str) -> Path:
run_dir = self.get_run_dir(config_name)
run_dir.mkdir(parents=True, exist_ok=True)
self.skills_manager.ensure_activation_manifest(config_name)
bootstrap_path = run_dir / "BOOTSTRAP.md"
if not bootstrap_path.exists():
bootstrap_path.write_text(
"---\n"
"tickers:\n"
" - AAPL\n"
" - MSFT\n"
"initial_cash: 100000\n"
"margin_requirement: 0.0\n"
"enable_memory: false\n"
"max_comm_cycles: 2\n"
"agent_overrides: {}\n"
"---\n\n"
"# Bootstrap\n\n"
"Use this file to describe run-specific setup notes, preferred tickers,\n"
"risk bounds, or strategy constraints before the first execution.\n\n"
"The YAML front matter above is machine-readable runtime configuration.\n"
"The markdown body below is injected into agent prompts as run context.\n",
encoding="utf-8",
)
return run_dir
def bootstrap_path(self, config_name: str) -> Path:
return self.get_run_dir(config_name) / "BOOTSTRAP.md"
def ensure_agent_assets(
self,
config_name: str,
agent_id: str,
role_seed: str = "",
style_seed: str = "",
policy_seed: str = "",
) -> Path:
asset_dir = self.skills_manager.get_agent_asset_dir(
config_name,
agent_id,
)
asset_dir.mkdir(parents=True, exist_ok=True)
self._ensure_file(
asset_dir / "ROLE.md",
"# Role\n\n"
"Optional run-scoped role override.\n\n"
f"{role_seed}".strip()
+ "\n",
)
self._ensure_file(
asset_dir / "STYLE.md",
"# Style\n\n"
"Optional run-scoped communication or reasoning style.\n\n"
f"{style_seed}".strip()
+ "\n",
)
self._ensure_file(
asset_dir / "POLICY.md",
"# Policy\n\n"
"Optional run-scoped constraints, limits, or strategy policy.\n\n"
f"{policy_seed}".strip()
+ "\n",
)
return asset_dir
def initialize_default_assets(
self,
config_name: str,
agent_ids: Iterable[str],
analyst_personas: Optional[Dict[str, Dict]] = None,
) -> None:
self.ensure_run_workspace(config_name)
analyst_personas = analyst_personas or {}
for agent_id in agent_ids:
if agent_id.endswith("_analyst"):
persona = analyst_personas.get(agent_id, {})
role_seed = persona.get("description", "").strip()
focus_items = persona.get("focus", [])
style_seed = "\n".join(f"- {item}" for item in focus_items)
policy_seed = (
"State a clear signal, confidence, and the conditions that would invalidate the thesis."
)
elif agent_id == "portfolio_manager":
role_seed = (
"Synthesize analyst and risk inputs into explicit portfolio decisions."
)
style_seed = (
"Be concise, capital-aware, and explicit about sizing rationale."
)
policy_seed = (
"Respect cash, margin, and portfolio concentration constraints before recording decisions."
)
elif agent_id == "risk_manager":
role_seed = (
"Quantify concentration, leverage, liquidity, and volatility risk before trade execution."
)
style_seed = (
"Prioritize the highest-severity risk first and state concrete limits."
)
policy_seed = (
"Use available risk tools before issuing the final risk memo."
)
else:
role_seed = ""
style_seed = ""
policy_seed = ""
self.ensure_agent_assets(
config_name=config_name,
agent_id=agent_id,
role_seed=role_seed,
style_seed=style_seed,
policy_seed=policy_seed,
)
@staticmethod
def _ensure_file(path: Path, content: str) -> None:
if not path.exists():
path.write_text(content, encoding="utf-8")

View File

@@ -22,6 +22,9 @@ from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.prompt import Confirm from rich.prompt import Confirm
from backend.agents.prompt_loader import PromptLoader
from backend.agents.workspace_manager import WorkspaceManager
app = typer.Typer( app = typer.Typer(
name="evotraders", name="evotraders",
help="EvoTraders: A self-evolving multi-agent trading system", help="EvoTraders: A self-evolving multi-agent trading system",
@@ -29,6 +32,7 @@ app = typer.Typer(
) )
console = Console() console = Console()
_prompt_loader = PromptLoader()
def get_project_root() -> Path: def get_project_root() -> Path:
@@ -45,9 +49,8 @@ def handle_history_cleanup(config_name: str, auto_clean: bool = False) -> None:
config_name: Configuration name for the run config_name: Configuration name for the run
auto_clean: If True, skip confirmation and clean automatically auto_clean: If True, skip confirmation and clean automatically
""" """
# logs_dir = get_project_root() / "logs" workspace_manager = WorkspaceManager(project_root=get_project_root())
logs_dir = get_project_root() base_data_dir = workspace_manager.get_run_dir(config_name)
base_data_dir = logs_dir / config_name
# Check if historical data exists # Check if historical data exists
if not base_data_dir.exists() or not any(base_data_dir.iterdir()): if not base_data_dir.exists() or not any(base_data_dir.iterdir()):
@@ -180,6 +183,46 @@ def run_data_updater(project_root: Path) -> None:
) )
def initialize_workspace(config_name: str) -> Path:
"""Create run-scoped workspace files for a config."""
workspace_manager = WorkspaceManager(project_root=get_project_root())
workspace_manager.initialize_default_assets(
config_name=config_name,
agent_ids=[
"fundamentals_analyst",
"technical_analyst",
"sentiment_analyst",
"valuation_analyst",
"risk_manager",
"portfolio_manager",
],
analyst_personas=_prompt_loader.load_yaml_config(
"analyst",
"personas",
),
)
return workspace_manager.get_run_dir(config_name)
@app.command("init-workspace")
def init_workspace(
config_name: str = typer.Option(
"default",
"--config-name",
"-c",
help="Configuration name for the workspace",
),
):
"""Initialize run-scoped BOOTSTRAP and agent prompt asset files."""
run_dir = initialize_workspace(config_name)
console.print(
Panel.fit(
f"[bold green]Workspace initialized[/bold green]\n[cyan]{run_dir}[/cyan]",
border_style="green",
),
)
@app.command() @app.command()
def backtest( def backtest(
start: Optional[str] = typer.Option( start: Optional[str] = typer.Option(

View File

@@ -0,0 +1,37 @@
fundamentals_analyst:
skills:
- fundamental_review
active_tool_groups:
- fundamentals
- valuation
technical_analyst:
skills:
- technical_review
active_tool_groups:
- technical
sentiment_analyst:
skills:
- sentiment_review
active_tool_groups:
- sentiment
valuation_analyst:
skills:
- valuation_review
active_tool_groups:
- valuation
- fundamentals
portfolio_manager:
skills:
- portfolio_decisioning
active_tool_groups:
- portfolio_ops
risk_manager:
skills:
- risk_review
active_tool_groups:
- risk_ops

View File

@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
"""Parse run-scoped BOOTSTRAP.md into structured and runtime config."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict
import re
import yaml
from backend.config.env_config import get_env_float, get_env_int, get_env_list
BOOTSTRAP_FRONT_MATTER_RE = re.compile(
r"^---\s*\n(.*?)\n---\s*\n?(.*)$",
re.DOTALL,
)
@dataclass(frozen=True)
class BootstrapConfig:
"""Structured configuration extracted from BOOTSTRAP.md."""
values: Dict[str, Any] = field(default_factory=dict)
prompt_body: str = ""
def get(self, key: str, default: Any = None) -> Any:
return self.values.get(key, default)
def agent_override(self, agent_id: str) -> Dict[str, Any]:
overrides = self.values.get("agent_overrides", {})
if not isinstance(overrides, dict):
return {}
override = overrides.get(agent_id, {})
return override if isinstance(override, dict) else {}
def load_bootstrap_config(bootstrap_path: Path) -> BootstrapConfig:
"""Load structured bootstrap config and free-form prompt body."""
if not bootstrap_path.exists():
return BootstrapConfig()
raw = bootstrap_path.read_text(encoding="utf-8").strip()
if not raw:
return BootstrapConfig()
match = BOOTSTRAP_FRONT_MATTER_RE.match(raw)
if not match:
return BootstrapConfig(prompt_body=raw)
front_matter = match.group(1).strip()
body = match.group(2).strip()
parsed = yaml.safe_load(front_matter) or {}
if not isinstance(parsed, dict):
parsed = {}
return BootstrapConfig(values=parsed, prompt_body=body)
def get_bootstrap_config_for_run(
project_root: Path,
config_name: str,
) -> BootstrapConfig:
"""Load BOOTSTRAP.md from the run workspace."""
return load_bootstrap_config(
project_root / "runs" / config_name / "BOOTSTRAP.md",
)
def save_bootstrap_config(bootstrap_path: Path, config: BootstrapConfig) -> None:
"""Persist structured bootstrap config back to BOOTSTRAP.md."""
bootstrap_path.parent.mkdir(parents=True, exist_ok=True)
values = config.values if isinstance(config.values, dict) else {}
front_matter = yaml.safe_dump(
values,
allow_unicode=True,
sort_keys=False,
).strip()
body = (config.prompt_body or "").strip()
content = f"---\n{front_matter}\n---"
if body:
content += f"\n\n{body}\n"
else:
content += "\n"
bootstrap_path.write_text(content, encoding="utf-8")
def update_bootstrap_values_for_run(
project_root: Path,
config_name: str,
updates: Dict[str, Any],
) -> BootstrapConfig:
"""Patch selected front matter keys for a run and persist them."""
bootstrap_path = project_root / "runs" / config_name / "BOOTSTRAP.md"
existing = load_bootstrap_config(bootstrap_path)
values = dict(existing.values)
values.update(updates)
updated = BootstrapConfig(values=values, prompt_body=existing.prompt_body)
save_bootstrap_config(bootstrap_path, updated)
return updated
def _coerce_bool(value: Any) -> bool:
"""Parse booleans from bootstrap-friendly string values."""
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def resolve_runtime_config(
project_root: Path,
config_name: str,
enable_memory: bool = False,
) -> Dict[str, Any]:
"""Merge env defaults with run-scoped bootstrap front matter."""
bootstrap = get_bootstrap_config_for_run(project_root, config_name)
return {
"tickers": bootstrap.get("tickers")
or get_env_list("TICKERS", ["AAPL", "MSFT"]),
"initial_cash": float(
bootstrap.get(
"initial_cash",
get_env_float("INITIAL_CASH", 100000.0),
),
),
"margin_requirement": float(
bootstrap.get(
"margin_requirement",
get_env_float("MARGIN_REQUIREMENT", 0.0),
),
),
"max_comm_cycles": int(
bootstrap.get(
"max_comm_cycles",
get_env_int("MAX_COMM_CYCLES", 2),
),
),
"enable_memory": bool(enable_memory)
or _coerce_bool(bootstrap.get("enable_memory", False)),
}

View File

@@ -226,6 +226,51 @@ class TradingPipeline:
"settlement_result": settlement_result, "settlement_result": settlement_result,
} }
def reload_runtime_assets(
self,
runtime_config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Reload prompt assets and safe in-process runtime settings."""
from backend.agents.skills_manager import SkillsManager
from backend.agents.toolkit_factory import load_agent_profiles
config_name = getattr(self.pm, "config", {}).get("config_name", "default")
if runtime_config and "max_comm_cycles" in runtime_config:
self.max_comm_cycles = int(runtime_config["max_comm_cycles"])
skills_manager = SkillsManager()
profiles = load_agent_profiles()
active_skill_map = skills_manager.prepare_active_skills(
config_name=config_name,
agent_defaults={
agent_id: profile.get("skills", [])
for agent_id, profile in profiles.items()
},
)
for analyst in self.analysts:
analyst.reload_runtime_assets(
active_skill_dirs=active_skill_map.get(analyst.name, []),
)
self.risk_manager.reload_runtime_assets(
active_skill_dirs=active_skill_map.get("risk_manager", []),
)
self.pm.reload_runtime_assets(
active_skill_dirs=active_skill_map.get("portfolio_manager", []),
)
return {
"config_name": config_name,
"reloaded_agents": [agent.name for agent in self.analysts]
+ ["risk_manager", "portfolio_manager"],
"active_skills": {
agent_id: [path.name for path in paths]
for agent_id, paths in active_skill_map.items()
},
"max_comm_cycles": self.max_comm_cycles,
}
async def _clear_all_agent_memory(self): async def _clear_all_agent_memory(self):
"""Clear short-term memory for all agents""" """Clear short-term memory for all agents"""
for analyst in self.analysts: for analyst in self.analysts:

View File

@@ -438,6 +438,8 @@ class StateSync:
"server_mode": self._state.get("server_mode", "live"), "server_mode": self._state.get("server_mode", "live"),
"is_mock_mode": self._state.get("is_mock_mode", False), "is_mock_mode": self._state.get("is_mock_mode", False),
"is_backtest": self._state.get("is_backtest", False), "is_backtest": self._state.get("is_backtest", False),
"tickers": self._state.get("tickers"),
"runtime_config": self._state.get("runtime_config"),
"feed_history": self._state.get("feed_history", []), "feed_history": self._state.get("feed_history", []),
"current_date": self._state.get("current_date"), "current_date": self._state.get("current_date"),
"trading_days_total": self._state.get("trading_days_total", 0), "trading_days_total": self._state.get("trading_days_total", 0),
@@ -452,6 +454,7 @@ class StateSync:
"portfolio": self._state.get("portfolio", {}), "portfolio": self._state.get("portfolio", {}),
"realtime_prices": self._state.get("realtime_prices", {}), "realtime_prices": self._state.get("realtime_prices", {}),
"data_sources": self._state.get("data_sources", {}), "data_sources": self._state.get("data_sources", {}),
"price_history": self._state.get("price_history", {}),
} }
if include_dashboard: if include_dashboard:

View File

@@ -30,6 +30,25 @@ logger = logging.getLogger(__name__)
_DATA_DIR = Path(__file__).parent / "ret_data" _DATA_DIR = Path(__file__).parent / "ret_data"
def _format_provider_error(exc: Exception) -> str:
"""Condense common provider failures into short, readable messages."""
message = str(exc).strip().replace("\n", " ")
if "429" in message:
return "rate limit reached"
if "402" in message:
return "insufficient credits"
if "422" in message or "Missing parameters" in message:
return "invalid request parameters"
if "Quote not found" in message:
return "quote not found"
return message
def _has_valid_ticker(ticker: str) -> bool:
"""Return whether the normalized ticker is non-empty."""
return bool((ticker or "").strip())
class DataProviderRouter: class DataProviderRouter:
"""Route data requests across configured providers with fallbacks.""" """Route data requests across configured providers with fallbacks."""
@@ -56,6 +75,8 @@ class DataProviderRouter:
end_date: str, end_date: str,
) -> tuple[list[Price], DataSource]: ) -> tuple[list[Price], DataSource]:
"""Fetch prices using preferred providers with fallback.""" """Fetch prices using preferred providers with fallback."""
if not _has_valid_ticker(ticker):
return [], "local_csv"
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for source in self.price_sources(): for source in self.price_sources():
@@ -78,7 +99,12 @@ class DataProviderRouter:
return prices, source return prices, source
except Exception as exc: except Exception as exc:
last_error = exc last_error = exc
logger.warning("Price source %s failed for %s: %s", source, ticker, exc) logger.warning(
"Price source %s failed for %s: %s",
source,
ticker,
_format_provider_error(exc),
)
if last_error: if last_error:
raise last_error raise last_error
@@ -92,6 +118,8 @@ class DataProviderRouter:
limit: int = 10, limit: int = 10,
) -> tuple[list[FinancialMetrics], DataSource]: ) -> tuple[list[FinancialMetrics], DataSource]:
"""Fetch financial metrics with API provider fallback.""" """Fetch financial metrics with API provider fallback."""
if not _has_valid_ticker(ticker):
return [], "local_csv"
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for source in self.api_sources(): for source in self.api_sources():
@@ -126,7 +154,7 @@ class DataProviderRouter:
"Financial metrics source %s failed for %s: %s", "Financial metrics source %s failed for %s: %s",
source, source,
ticker, ticker,
exc, _format_provider_error(exc),
) )
if last_error: if last_error:
@@ -142,6 +170,8 @@ class DataProviderRouter:
limit: int = 10, limit: int = 10,
) -> list[LineItem]: ) -> list[LineItem]:
"""Line items are only supported via Financial Datasets.""" """Line items are only supported via Financial Datasets."""
if not _has_valid_ticker(ticker):
return []
if "financial_datasets" not in self.api_sources(): if "financial_datasets" not in self.api_sources():
return [] return []
try: try:
@@ -155,7 +185,11 @@ class DataProviderRouter:
self._record_success("line_items", "financial_datasets") self._record_success("line_items", "financial_datasets")
return results return results
except Exception as exc: except Exception as exc:
logger.warning("Line items source failed for %s: %s", ticker, exc) logger.warning(
"Line items source failed for %s: %s",
ticker,
_format_provider_error(exc),
)
return [] return []
def get_insider_trades( def get_insider_trades(
@@ -166,6 +200,8 @@ class DataProviderRouter:
limit: int = 1000, limit: int = 1000,
) -> tuple[list[InsiderTrade], DataSource]: ) -> tuple[list[InsiderTrade], DataSource]:
"""Fetch insider trades with provider fallback.""" """Fetch insider trades with provider fallback."""
if not _has_valid_ticker(ticker):
return [], "local_csv"
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for source in self.api_sources(): for source in self.api_sources():
@@ -193,7 +229,7 @@ class DataProviderRouter:
"Insider trades source %s failed for %s: %s", "Insider trades source %s failed for %s: %s",
source, source,
ticker, ticker,
exc, _format_provider_error(exc),
) )
if last_error: if last_error:
@@ -208,6 +244,8 @@ class DataProviderRouter:
limit: int = 1000, limit: int = 1000,
) -> tuple[list[CompanyNews], DataSource]: ) -> tuple[list[CompanyNews], DataSource]:
"""Fetch company news with provider fallback.""" """Fetch company news with provider fallback."""
if not _has_valid_ticker(ticker):
return [], "local_csv"
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for source in self.api_sources(): for source in self.api_sources():
@@ -244,7 +282,7 @@ class DataProviderRouter:
"Company news source %s failed for %s: %s", "Company news source %s failed for %s: %s",
source, source,
ticker, ticker,
exc, _format_provider_error(exc),
) )
if last_error: if last_error:
@@ -258,6 +296,8 @@ class DataProviderRouter:
metrics_lookup, metrics_lookup,
) -> tuple[Optional[float], DataSource]: ) -> tuple[Optional[float], DataSource]:
"""Fetch market cap using facts API or financial metrics fallback.""" """Fetch market cap using facts API or financial metrics fallback."""
if not _has_valid_ticker(ticker):
return None, "local_csv"
today = datetime.datetime.now().strftime("%Y-%m-%d") today = datetime.datetime.now().strftime("%Y-%m-%d")
if end_date == today and "financial_datasets" in self.api_sources(): if end_date == today and "financial_datasets" in self.api_sources():
try: try:
@@ -267,7 +307,7 @@ class DataProviderRouter:
logger.warning( logger.warning(
"Market cap facts source failed for %s: %s", "Market cap facts source failed for %s: %s",
ticker, ticker,
exc, _format_provider_error(exc),
) )
metrics, source = metrics_lookup(ticker, end_date) metrics, source = metrics_lookup(ticker, end_date)

View File

@@ -14,8 +14,12 @@ import loguru
from dotenv import load_dotenv from dotenv import load_dotenv
from backend.agents import AnalystAgent, PMAgent, RiskAgent from backend.agents import AnalystAgent, PMAgent, RiskAgent
from backend.agents.skills_manager import SkillsManager
from backend.agents.toolkit_factory import create_agent_toolkit, load_agent_profiles
from backend.agents.prompt_loader import PromptLoader
from backend.agents.workspace_manager import WorkspaceManager
from backend.config.bootstrap_config import resolve_runtime_config
from backend.config.constants import ANALYST_TYPES from backend.config.constants import ANALYST_TYPES
from backend.config.env_config import get_env_float, get_env_int, get_env_list
from backend.core.pipeline import TradingPipeline from backend.core.pipeline import TradingPipeline
from backend.core.scheduler import BacktestScheduler, Scheduler from backend.core.scheduler import BacktestScheduler, Scheduler
from backend.utils.settlement import SettlementCoordinator from backend.utils.settlement import SettlementCoordinator
@@ -28,6 +32,23 @@ load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
loguru.logger.disable("flowllm") loguru.logger.disable("flowllm")
loguru.logger.disable("reme_ai") loguru.logger.disable("reme_ai")
_prompt_loader = PromptLoader()
def _get_run_dir(config_name: str) -> Path:
"""Return the canonical run-scoped directory for a config."""
project_root = Path(__file__).resolve().parents[1]
return WorkspaceManager(project_root=project_root).get_run_dir(config_name)
def _resolve_runtime_config(args) -> dict:
"""Merge env defaults with run-scoped bootstrap config."""
project_root = Path(__file__).resolve().parents[1]
return resolve_runtime_config(
project_root=project_root,
config_name=args.config_name,
enable_memory=args.enable_memory,
)
def create_long_term_memory(agent_name: str, config_name: str): def create_long_term_memory(agent_name: str, config_name: str):
@@ -45,7 +66,7 @@ def create_long_term_memory(agent_name: str, config_name: str):
logger.warning("MEMORY_API_KEY not set, long-term memory disabled") logger.warning("MEMORY_API_KEY not set, long-term memory disabled")
return None return None
memory_dir = str(Path(config_name) / "memory") memory_dir = str(_get_run_dir(config_name) / "memory")
return ReMeTaskLongTermMemory( return ReMeTaskLongTermMemory(
agent_name=agent_name, agent_name=agent_name,
@@ -84,11 +105,31 @@ def create_agents(
""" """
analysts = [] analysts = []
long_term_memories = [] long_term_memories = []
workspace_manager = WorkspaceManager()
workspace_manager.initialize_default_assets(
config_name=config_name,
agent_ids=list(ANALYST_TYPES.keys())
+ ["risk_manager", "portfolio_manager"],
analyst_personas=_prompt_loader.load_yaml_config("analyst", "personas"),
)
profiles = load_agent_profiles()
skills_manager = SkillsManager()
active_skill_map = skills_manager.prepare_active_skills(
config_name=config_name,
agent_defaults={
agent_id: profile.get("skills", [])
for agent_id, profile in profiles.items()
},
)
for analyst_type in ANALYST_TYPES: for analyst_type in ANALYST_TYPES:
model = get_agent_model(analyst_type) model = get_agent_model(analyst_type)
formatter = get_agent_formatter(analyst_type) formatter = get_agent_formatter(analyst_type)
toolkit = create_toolkit(analyst_type) toolkit = create_agent_toolkit(
analyst_type,
config_name,
active_skill_dirs=active_skill_map.get(analyst_type, []),
)
long_term_memory = None long_term_memory = None
if enable_long_term_memory: if enable_long_term_memory:
@@ -125,6 +166,11 @@ def create_agents(
name="risk_manager", name="risk_manager",
config={"config_name": config_name}, config={"config_name": config_name},
long_term_memory=risk_long_term_memory, long_term_memory=risk_long_term_memory,
toolkit=create_agent_toolkit(
"risk_manager",
config_name,
active_skill_dirs=active_skill_map.get("risk_manager", []),
),
) )
pm_long_term_memory = None pm_long_term_memory = None
@@ -144,44 +190,25 @@ def create_agents(
margin_requirement=margin_requirement, margin_requirement=margin_requirement,
config={"config_name": config_name}, config={"config_name": config_name},
long_term_memory=pm_long_term_memory, long_term_memory=pm_long_term_memory,
toolkit_factory=create_agent_toolkit,
toolkit_factory_kwargs={
"active_skill_dirs": active_skill_map.get(
"portfolio_manager",
[],
),
},
) )
return analysts, risk_manager, portfolio_manager, long_term_memories return analysts, risk_manager, portfolio_manager, long_term_memories
def create_toolkit(analyst_type: str):
"""Create AgentScope Toolkit with tools for specific analyst type"""
from agentscope.tool import Toolkit
from backend.agents.prompt_loader import PromptLoader
from backend.tools.analysis_tools import TOOL_REGISTRY
# Load analyst persona config
prompt_loader = PromptLoader()
personas_config = prompt_loader.load_yaml_config("analyst", "personas")
persona = personas_config.get(analyst_type, {})
# Get tool names for this analyst type
tool_names = persona.get("tools", [])
# Create toolkit and register tools
toolkit = Toolkit()
for tool_name in tool_names:
tool_func = TOOL_REGISTRY.get(tool_name)
if tool_func:
toolkit.register_tool_function(tool_func)
return toolkit
async def run_with_gateway(args): async def run_with_gateway(args):
"""Run with WebSocket gateway""" """Run with WebSocket gateway"""
is_backtest = args.mode == "backtest" is_backtest = args.mode == "backtest"
runtime_config = _resolve_runtime_config(args)
# Load config from env, override with args
tickers = get_env_list("TICKERS", ["AAPL", "MSFT"])
initial_cash = get_env_float("INITIAL_CASH", 100000.0)
margin_requirement = get_env_float("MARGIN_REQUIREMENT", 0.0)
config_name = args.config_name config_name = args.config_name
tickers = runtime_config["tickers"]
initial_cash = runtime_config["initial_cash"]
margin_requirement = runtime_config["margin_requirement"]
# Create market service # Create market service
market_service = MarketService( market_service = MarketService(
@@ -198,7 +225,7 @@ async def run_with_gateway(args):
# Create storage service # Create storage service
storage_service = StorageService( storage_service = StorageService(
dashboard_dir=Path(config_name) / "team_dashboard", dashboard_dir=_get_run_dir(config_name) / "team_dashboard",
initial_cash=initial_cash, initial_cash=initial_cash,
config_name=config_name, config_name=config_name,
) )
@@ -213,7 +240,7 @@ async def run_with_gateway(args):
config_name=config_name, config_name=config_name,
initial_cash=initial_cash, initial_cash=initial_cash,
margin_requirement=margin_requirement, margin_requirement=margin_requirement,
enable_long_term_memory=args.enable_memory, enable_long_term_memory=runtime_config["enable_memory"],
) )
portfolio_state = storage_service.load_portfolio_state() portfolio_state = storage_service.load_portfolio_state()
pm.load_portfolio_state(portfolio_state) pm.load_portfolio_state(portfolio_state)
@@ -228,7 +255,7 @@ async def run_with_gateway(args):
risk_manager=risk_manager, risk_manager=risk_manager,
portfolio_manager=pm, portfolio_manager=pm,
settlement_coordinator=settlement_coordinator, settlement_coordinator=settlement_coordinator,
max_comm_cycles=get_env_int("MAX_COMM_CYCLES", 2), max_comm_cycles=runtime_config["max_comm_cycles"],
) )
# Create scheduler callback # Create scheduler callback
@@ -273,6 +300,10 @@ async def run_with_gateway(args):
"backtest_mode": is_backtest, "backtest_mode": is_backtest,
"tickers": tickers, "tickers": tickers,
"config_name": config_name, "config_name": config_name,
"initial_cash": initial_cash,
"margin_requirement": margin_requirement,
"max_comm_cycles": runtime_config["max_comm_cycles"],
"enable_memory": runtime_config["enable_memory"],
}, },
) )
@@ -307,15 +338,17 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Load config from env for logging # Load config from env for logging
tickers = get_env_list("TICKERS", ["AAPL", "MSFT"]) runtime_config = _resolve_runtime_config(args)
initial_cash = get_env_float("INITIAL_CASH", 100000.0) tickers = runtime_config["tickers"]
initial_cash = runtime_config["initial_cash"]
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"Mode: {args.mode}, Config: {args.config_name}") logger.info(f"Mode: {args.mode}, Config: {args.config_name}")
logger.info(f"Tickers: {tickers}") logger.info(f"Tickers: {tickers}")
logger.info(f"Initial Cash: ${initial_cash:,.2f}") logger.info(f"Initial Cash: ${initial_cash:,.2f}")
logger.info( logger.info(
f"Long-term Memory: {'enabled' if args.enable_memory else 'disabled'}", "Long-term Memory: %s",
"enabled" if runtime_config["enable_memory"] else "disabled",
) )
if args.mode == "backtest": if args.mode == "backtest":
if not args.start_date or not args.end_date: if not args.start_date or not args.end_date:

View File

@@ -5,12 +5,18 @@ WebSocket Gateway for frontend communication
import asyncio import asyncio
import json import json
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set from typing import Any, Callable, Dict, List, Optional, Set
import websockets import websockets
from websockets.server import WebSocketServerProtocol from websockets.asyncio.server import ServerConnection
from backend.config.bootstrap_config import (
resolve_runtime_config,
update_bootstrap_values_for_run,
)
from backend.data.provider_utils import normalize_symbol
from backend.utils.msg_adapter import FrontendAdapter from backend.utils.msg_adapter import FrontendAdapter
from backend.utils.terminal_dashboard import get_dashboard from backend.utils.terminal_dashboard import get_dashboard
from backend.core.pipeline import TradingPipeline from backend.core.pipeline import TradingPipeline
@@ -18,6 +24,7 @@ from backend.core.state_sync import StateSync
from backend.services.market import MarketService from backend.services.market import MarketService
from backend.services.storage import StorageService from backend.services.storage import StorageService
from backend.data.provider_router import get_provider_router from backend.data.provider_router import get_provider_router
from backend.tools.data_tools import get_prices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -51,7 +58,7 @@ class Gateway:
self.state_sync.set_broadcast_fn(self.broadcast) self.state_sync.set_broadcast_fn(self.broadcast)
self.pipeline.state_sync = self.state_sync self.pipeline.state_sync = self.state_sync
self.connected_clients: Set[WebSocketServerProtocol] = set() self.connected_clients: Set[ServerConnection] = set()
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
self._backtest_task: Optional[asyncio.Task] = None self._backtest_task: Optional[asyncio.Task] = None
self._backtest_start_date: Optional[str] = None self._backtest_start_date: Optional[str] = None
@@ -63,6 +70,7 @@ class Gateway:
self._session_start_portfolio_value: Optional[float] = None self._session_start_portfolio_value: Optional[float] = None
self._provider_router = get_provider_router() self._provider_router = get_provider_router()
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
self._project_root = Path(__file__).resolve().parents[2]
async def start(self, host: str = "0.0.0.0", port: int = 8766): async def start(self, host: str = "0.0.0.0", port: int = 8766):
"""Start gateway server""" """Start gateway server"""
@@ -87,6 +95,7 @@ class Gateway:
self._dashboard.start() self._dashboard.start()
self.state_sync.load_state() self.state_sync.load_state()
self.market_service.set_price_recorder(self.storage.record_price_point)
self.state_sync.update_state("status", "running") self.state_sync.update_state("status", "running")
self.state_sync.update_state("server_mode", self.mode) self.state_sync.update_state("server_mode", self.mode)
self.state_sync.update_state("is_backtest", self.is_backtest) self.state_sync.update_state("is_backtest", self.is_backtest)
@@ -94,6 +103,20 @@ class Gateway:
"is_mock_mode", "is_mock_mode",
self.config.get("mock_mode", False), self.config.get("mock_mode", False),
) )
self.state_sync.update_state("tickers", self.config.get("tickers", []))
self.state_sync.update_state(
"runtime_config",
{
"tickers": self.config.get("tickers", []),
"initial_cash": self.config.get(
"initial_cash",
self.storage.initial_cash,
),
"margin_requirement": self.config.get("margin_requirement"),
"max_comm_cycles": self.config.get("max_comm_cycles"),
"enable_memory": self.config.get("enable_memory", False),
},
)
self.state_sync.update_state( self.state_sync.update_state(
"data_sources", "data_sources",
self._provider_router.get_usage_snapshot(), self._provider_router.get_usage_snapshot(),
@@ -159,7 +182,7 @@ class Gateway:
def state(self) -> Dict[str, Any]: def state(self) -> Dict[str, Any]:
return self.state_sync.state return self.state_sync.state
async def handle_client(self, websocket: WebSocketServerProtocol): async def handle_client(self, websocket: ServerConnection):
"""Handle WebSocket client connection""" """Handle WebSocket client connection"""
async with self.lock: async with self.lock:
self.connected_clients.add(websocket) self.connected_clients.add(websocket)
@@ -170,7 +193,7 @@ class Gateway:
async with self.lock: async with self.lock:
self.connected_clients.discard(websocket) self.connected_clients.discard(websocket)
async def _send_initial_state(self, websocket: WebSocketServerProtocol): async def _send_initial_state(self, websocket: ServerConnection):
state_payload = self.state_sync.get_initial_state_payload( state_payload = self.state_sync.get_initial_state_payload(
include_dashboard=True, include_dashboard=True,
) )
@@ -198,7 +221,7 @@ class Gateway:
async def _handle_client_messages( async def _handle_client_messages(
self, self,
websocket: WebSocketServerProtocol, websocket: ServerConnection,
): ):
try: try:
async for message in websocket: async for message in websocket:
@@ -219,12 +242,106 @@ class Gateway:
await self._send_initial_state(websocket) await self._send_initial_state(websocket)
elif msg_type == "start_backtest": elif msg_type == "start_backtest":
await self._handle_start_backtest(data) await self._handle_start_backtest(data)
elif msg_type == "reload_runtime_assets":
await self._handle_reload_runtime_assets()
elif msg_type == "update_watchlist":
await self._handle_update_watchlist(websocket, data)
elif msg_type == "get_stock_history":
await self._handle_get_stock_history(websocket, data)
elif msg_type == "get_stock_explain_events":
await self._handle_get_stock_explain_events(websocket, data)
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
pass pass
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
async def _handle_get_stock_history(
self,
websocket: ServerConnection,
data: Dict[str, Any],
):
ticker = normalize_symbol(data.get("ticker", ""))
if not ticker:
await websocket.send(
json.dumps(
{
"type": "stock_history_loaded",
"ticker": "",
"prices": [],
"source": None,
"error": "invalid ticker",
},
ensure_ascii=False,
),
)
return
lookback_days = data.get("lookback_days", 90)
try:
lookback_days = max(7, min(int(lookback_days), 365))
except (TypeError, ValueError):
lookback_days = 90
end_date = self.state_sync.state.get("current_date")
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
except ValueError:
end_dt = datetime.now()
end_date = end_dt.strftime("%Y-%m-%d")
start_date = (end_dt - timedelta(days=lookback_days)).strftime(
"%Y-%m-%d",
)
prices = await asyncio.to_thread(
get_prices,
ticker,
start_date,
end_date,
)
usage_snapshot = self._provider_router.get_usage_snapshot()
source = usage_snapshot.get("last_success", {}).get("prices")
await websocket.send(
json.dumps(
{
"type": "stock_history_loaded",
"ticker": ticker,
"prices": [price.model_dump() for price in prices][-120:],
"source": source,
"start_date": start_date,
"end_date": end_date,
},
ensure_ascii=False,
default=str,
),
)
async def _handle_get_stock_explain_events(
self,
websocket: ServerConnection,
data: Dict[str, Any],
):
ticker = normalize_symbol(data.get("ticker", ""))
snapshot = self.storage.runtime_db.get_stock_explain_snapshot(ticker)
await websocket.send(
json.dumps(
{
"type": "stock_explain_events_loaded",
"ticker": ticker,
"events": snapshot.get("events", []),
"signals": snapshot.get("signals", []),
"trades": snapshot.get("trades", []),
},
ensure_ascii=False,
default=str,
),
)
async def _handle_start_backtest(self, data: Dict[str, Any]): async def _handle_start_backtest(self, data: Dict[str, Any]):
if not self.is_backtest: if not self.is_backtest:
return return
@@ -236,6 +353,191 @@ class Gateway:
task.add_done_callback(self._handle_backtest_exception) task.add_done_callback(self._handle_backtest_exception)
self._backtest_task = task self._backtest_task = task
async def _handle_reload_runtime_assets(self):
"""Reload prompt, skills, and safe runtime config without restart."""
config_name = self.config.get("config_name", "default")
runtime_config = resolve_runtime_config(
project_root=self._project_root,
config_name=config_name,
enable_memory=self.config.get("enable_memory", False),
)
result = self.pipeline.reload_runtime_assets(runtime_config=runtime_config)
runtime_updates = self._apply_runtime_config(runtime_config)
await self.state_sync.on_system_message(
"Runtime assets reloaded.",
)
await self.broadcast(
{
"type": "runtime_assets_reloaded",
**result,
**runtime_updates,
},
)
async def _handle_update_watchlist(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
"""Persist a new watchlist to BOOTSTRAP.md and hot-reload it."""
tickers = self._normalize_watchlist(data.get("tickers"))
if not tickers:
await websocket.send(
json.dumps(
{
"type": "error",
"message": "update_watchlist requires at least one valid ticker.",
},
ensure_ascii=False,
),
)
return
config_name = self.config.get("config_name", "default")
update_bootstrap_values_for_run(
project_root=self._project_root,
config_name=config_name,
updates={"tickers": tickers},
)
await self.state_sync.on_system_message(
f"Watchlist updated: {', '.join(tickers)}",
)
await self.broadcast(
{
"type": "watchlist_updated",
"config_name": config_name,
"tickers": tickers,
},
)
await self._handle_reload_runtime_assets()
@staticmethod
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
"""Parse watchlist payloads from websocket messages."""
if raw_tickers is None:
return []
if isinstance(raw_tickers, str):
candidates = raw_tickers.split(",")
elif isinstance(raw_tickers, list):
candidates = raw_tickers
else:
candidates = [raw_tickers]
tickers: List[str] = []
for candidate in candidates:
symbol = normalize_symbol(str(candidate).strip().strip("\"'"))
if symbol and symbol not in tickers:
tickers.append(symbol)
return tickers
def _apply_runtime_config(
self,
runtime_config: Dict[str, Any],
) -> Dict[str, Any]:
"""Apply runtime config to gateway-owned services and state."""
warnings: List[str] = []
ticker_changes = self.market_service.update_tickers(
runtime_config.get("tickers", []),
)
self.config["tickers"] = ticker_changes["active"]
self.pipeline.max_comm_cycles = int(runtime_config["max_comm_cycles"])
self.config["max_comm_cycles"] = self.pipeline.max_comm_cycles
pm_apply_result = self.pipeline.pm.apply_runtime_portfolio_config(
margin_requirement=runtime_config["margin_requirement"],
)
self.config["margin_requirement"] = self.pipeline.pm.portfolio.get(
"margin_requirement",
runtime_config["margin_requirement"],
)
requested_initial_cash = float(runtime_config["initial_cash"])
current_initial_cash = float(self.storage.initial_cash)
initial_cash_applied = requested_initial_cash == current_initial_cash
if not initial_cash_applied:
if (
self.storage.can_apply_initial_cash()
and self.pipeline.pm.can_apply_initial_cash()
):
initial_cash_applied = self.storage.apply_initial_cash(
requested_initial_cash,
)
if initial_cash_applied:
self.pipeline.pm.apply_runtime_portfolio_config(
initial_cash=requested_initial_cash,
)
self.config["initial_cash"] = self.storage.initial_cash
else:
warnings.append(
"initial_cash changed in BOOTSTRAP.md but was not applied "
"because the run already has positions, margin usage, or trades.",
)
requested_enable_memory = bool(runtime_config["enable_memory"])
current_enable_memory = bool(self.config.get("enable_memory", False))
if requested_enable_memory != current_enable_memory:
warnings.append(
"enable_memory changed in BOOTSTRAP.md but still requires a restart "
"because long-term memory contexts are created at startup.",
)
self._sync_runtime_state()
return {
"runtime_config_requested": runtime_config,
"runtime_config_applied": {
"tickers": list(self.config.get("tickers", [])),
"initial_cash": self.storage.initial_cash,
"margin_requirement": self.config["margin_requirement"],
"max_comm_cycles": self.config["max_comm_cycles"],
"enable_memory": self.config.get("enable_memory", False),
},
"runtime_config_status": {
"tickers": True,
"initial_cash": initial_cash_applied,
"margin_requirement": pm_apply_result["margin_requirement"],
"max_comm_cycles": True,
"enable_memory": requested_enable_memory == current_enable_memory,
},
"ticker_changes": ticker_changes,
"runtime_config_warnings": warnings,
}
def _sync_runtime_state(self) -> None:
"""Refresh persisted state and dashboard after runtime config changes."""
self.state_sync.update_state("tickers", self.config.get("tickers", []))
self.state_sync.update_state(
"runtime_config",
{
"tickers": self.config.get("tickers", []),
"initial_cash": self.storage.initial_cash,
"margin_requirement": self.config.get("margin_requirement"),
"max_comm_cycles": self.config.get("max_comm_cycles"),
"enable_memory": self.config.get("enable_memory", False),
},
)
self.storage.update_server_state_from_dashboard(self.state_sync.state)
self.state_sync.save_state()
self._dashboard.tickers = list(self.config.get("tickers", []))
self._dashboard.initial_cash = self.storage.initial_cash
self._dashboard.enable_memory = bool(
self.config.get("enable_memory", False),
)
summary = self.storage.load_file("summary") or {}
holdings = self.storage.load_file("holdings") or []
trades = self.storage.load_file("trades") or []
self._dashboard.update(
portfolio=summary,
holdings=holdings,
trades=trades,
)
async def broadcast(self, message: Dict[str, Any]): async def broadcast(self, message: Dict[str, Any]):
"""Broadcast message to all connected clients""" """Broadcast message to all connected clients"""
if not self.connected_clients: if not self.connected_clients:
@@ -254,7 +556,7 @@ class Gateway:
async def _send_to_client( async def _send_to_client(
self, self,
client: WebSocketServerProtocol, client: ServerConnection,
message: str, message: str,
): ):
try: try:

View File

@@ -54,6 +54,7 @@ class MarketService:
self.running = False self.running = False
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
self._broadcast_func: Optional[Callable] = None self._broadcast_func: Optional[Callable] = None
self._price_record_func: Optional[Callable[..., None]] = None
self._price_manager: Optional[Any] = None self._price_manager: Optional[Any] = None
self._current_date: Optional[str] = None self._current_date: Optional[str] = None
@@ -92,6 +93,10 @@ class MarketService:
f"Market service started: {self.mode_name}, tickers={self.tickers}", # noqa: E501 f"Market service started: {self.mode_name}, tickers={self.tickers}", # noqa: E501
) )
def set_price_recorder(self, recorder: Optional[Callable[..., None]]):
"""Register an optional callback for persisting runtime price points."""
self._price_record_func = recorder
def _make_price_callback(self) -> Callable: def _make_price_callback(self) -> Callable:
"""Create thread-safe price callback""" """Create thread-safe price callback"""
@@ -169,6 +174,24 @@ class MarketService:
((price - open_price) / open_price) * 100 if open_price > 0 else 0 ((price - open_price) / open_price) * 100 if open_price > 0 else 0
) )
if self._price_record_func:
try:
self._price_record_func(
ticker=symbol,
timestamp=str(price_data.get("timestamp") or datetime.now().isoformat()),
price=float(price),
open_price=float(open_price) if open_price is not None else None,
ret=float(ret),
source=self.mode_name.lower(),
meta=price_data,
)
except Exception as exc:
logger.warning(
"Failed to record price point for %s: %s",
symbol,
exc,
)
await self._broadcast_func( await self._broadcast_func(
{ {
"type": "price_update", "type": "price_update",
@@ -205,6 +228,43 @@ class MarketService:
self._loop = None self._loop = None
self._broadcast_func = None self._broadcast_func = None
def update_tickers(self, tickers: List[str]) -> Dict[str, List[str]]:
"""Hot-update subscribed tickers without restarting the service."""
normalized: List[str] = []
for ticker in tickers:
symbol = normalize_symbol(ticker)
if symbol and symbol not in normalized:
normalized.append(symbol)
previous = list(self.tickers)
removed = [ticker for ticker in previous if ticker not in normalized]
added = [ticker for ticker in normalized if ticker not in previous]
self.tickers = normalized
if self._price_manager:
if removed:
self._price_manager.unsubscribe(removed)
if added:
if self.mock_mode:
self._price_manager.subscribe(
added,
base_prices={ticker: 100.0 for ticker in added},
)
else:
self._price_manager.subscribe(added)
if self.backtest_mode and self._current_date:
self._price_manager.set_date(self._current_date)
for ticker in removed:
self.cache.pop(ticker, None)
return {
"added": added,
"removed": removed,
"active": list(self.tickers),
}
# Backtest methods # Backtest methods
def set_backtest_date(self, date: str): def set_backtest_date(self, date: str):
"""Set current backtest date""" """Set current backtest date"""

View File

@@ -0,0 +1,388 @@
# -*- coding: utf-8 -*-
"""Run-scoped SQLite storage for query-oriented runtime history."""
from __future__ import annotations
import hashlib
import json
import sqlite3
from pathlib import Path
from typing import Any, Dict, Iterable, Optional
SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
timestamp TEXT,
agent_id TEXT,
agent_name TEXT,
ticker TEXT,
title TEXT,
content TEXT,
payload_json TEXT NOT NULL,
run_date TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_events_ticker_time ON events(ticker, timestamp DESC);
CREATE TABLE IF NOT EXISTS trades (
id TEXT PRIMARY KEY,
ticker TEXT NOT NULL,
side TEXT,
qty REAL,
price REAL,
timestamp TEXT,
trading_date TEXT,
agent_id TEXT,
meta_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_trades_ticker_time ON trades(ticker, timestamp DESC);
CREATE TABLE IF NOT EXISTS signals (
id TEXT PRIMARY KEY,
ticker TEXT NOT NULL,
agent_id TEXT,
agent_name TEXT,
role TEXT,
signal TEXT,
confidence REAL,
reasoning_json TEXT,
real_return REAL,
is_correct TEXT,
trade_date TEXT,
created_at TEXT,
meta_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_signals_ticker_date ON signals(ticker, trade_date DESC);
CREATE INDEX IF NOT EXISTS idx_signals_agent_date ON signals(agent_id, trade_date DESC);
CREATE TABLE IF NOT EXISTS price_points (
id TEXT PRIMARY KEY,
ticker TEXT NOT NULL,
timestamp TEXT NOT NULL,
price REAL NOT NULL,
open_price REAL,
ret REAL,
source TEXT,
meta_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_price_points_ticker_time ON price_points(ticker, timestamp DESC);
"""
def _json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
def _hash_key(*parts: Any) -> str:
raw = "::".join("" if part is None else str(part) for part in parts)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
class RuntimeDb:
"""Small SQLite helper for append-mostly runtime data."""
def __init__(self, db_path: Path):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _init_db(self):
with self._connect() as conn:
conn.executescript(SCHEMA)
def insert_event(self, event: Dict[str, Any]):
payload = dict(event or {})
if not payload:
return
event_id = payload.get("id") or _hash_key(
payload.get("type"),
payload.get("timestamp"),
payload.get("agentId") or payload.get("agent_id"),
payload.get("content"),
payload.get("title"),
)
ticker = payload.get("ticker")
if not ticker and isinstance(payload.get("tickers"), list) and len(payload["tickers"]) == 1:
ticker = payload["tickers"][0]
with self._connect() as conn:
conn.execute(
"""
INSERT OR IGNORE INTO events
(id, event_type, timestamp, agent_id, agent_name, ticker, title, content, payload_json, run_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event_id,
payload.get("type"),
payload.get("timestamp"),
payload.get("agentId") or payload.get("agent_id"),
payload.get("agentName") or payload.get("agent_name"),
ticker,
payload.get("title"),
payload.get("content"),
_json_dumps(payload),
payload.get("date") or payload.get("trading_date") or payload.get("run_date"),
),
)
def upsert_trade(self, trade: Dict[str, Any]):
payload = dict(trade or {})
if not payload:
return
trade_id = payload.get("id") or _hash_key(
payload.get("ticker"),
payload.get("timestamp") or payload.get("ts"),
payload.get("side"),
payload.get("qty"),
payload.get("price"),
)
with self._connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO trades
(id, ticker, side, qty, price, timestamp, trading_date, agent_id, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
trade_id,
payload.get("ticker"),
payload.get("side"),
payload.get("qty"),
payload.get("price"),
payload.get("timestamp") or payload.get("ts"),
payload.get("trading_date"),
payload.get("agentId") or payload.get("agent_id"),
_json_dumps(payload),
),
)
def upsert_signal(self, signal: Dict[str, Any], *, agent_id: str, agent_name: str, role: str):
payload = dict(signal or {})
ticker = payload.get("ticker")
if not ticker:
return
signal_id = _hash_key(
agent_id,
ticker,
payload.get("date"),
payload.get("signal"),
payload.get("confidence"),
)
with self._connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO signals
(id, ticker, agent_id, agent_name, role, signal, confidence, reasoning_json,
real_return, is_correct, trade_date, created_at, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
signal_id,
ticker,
agent_id,
agent_name,
role,
payload.get("signal"),
payload.get("confidence"),
_json_dumps(payload.get("reasoning")),
payload.get("real_return"),
None if payload.get("is_correct") is None else str(payload.get("is_correct")),
payload.get("date"),
payload.get("created_at") or payload.get("date"),
_json_dumps(payload),
),
)
def replace_signals_for_leaderboard(self, leaderboard: Iterable[Dict[str, Any]]):
with self._connect() as conn:
conn.execute("DELETE FROM signals")
for agent in leaderboard:
agent_id = agent.get("agentId")
agent_name = agent.get("name")
role = agent.get("role")
for signal in agent.get("signals", []) or []:
payload = dict(signal or {})
ticker = payload.get("ticker")
if not ticker:
continue
signal_id = _hash_key(
agent_id,
ticker,
payload.get("date"),
payload.get("signal"),
payload.get("confidence"),
)
conn.execute(
"""
INSERT INTO signals
(id, ticker, agent_id, agent_name, role, signal, confidence, reasoning_json,
real_return, is_correct, trade_date, created_at, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
signal_id,
ticker,
agent_id,
agent_name,
role,
payload.get("signal"),
payload.get("confidence"),
_json_dumps(payload.get("reasoning")),
payload.get("real_return"),
None if payload.get("is_correct") is None else str(payload.get("is_correct")),
payload.get("date"),
payload.get("created_at") or payload.get("date"),
_json_dumps(payload),
),
)
def insert_price_point(
self,
*,
ticker: str,
timestamp: str,
price: float,
open_price: Optional[float] = None,
ret: Optional[float] = None,
source: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
):
price_id = _hash_key(ticker, timestamp, price, open_price, ret)
with self._connect() as conn:
conn.execute(
"""
INSERT OR IGNORE INTO price_points
(id, ticker, timestamp, price, open_price, ret, source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
price_id,
ticker,
timestamp,
price,
open_price,
ret,
source,
_json_dumps(meta or {}),
),
)
def get_stock_explain_snapshot(
self,
ticker: str,
*,
limit_events: int = 24,
limit_trades: int = 12,
limit_signals: int = 12,
) -> Dict[str, list[Dict[str, Any]]]:
"""Fetch query-oriented history for a single ticker."""
symbol = str(ticker or "").strip().upper()
if not symbol:
return {"events": [], "trades": [], "signals": []}
with self._connect() as conn:
trade_rows = conn.execute(
"""
SELECT * FROM trades
WHERE ticker = ?
ORDER BY timestamp DESC
LIMIT ?
""",
(symbol, limit_trades),
).fetchall()
signal_rows = conn.execute(
"""
SELECT * FROM signals
WHERE ticker = ?
ORDER BY trade_date DESC, created_at DESC
LIMIT ?
""",
(symbol, limit_signals),
).fetchall()
event_rows = conn.execute(
"""
SELECT * FROM events
WHERE payload_json LIKE ? OR content LIKE ? OR title LIKE ? OR ticker = ?
ORDER BY timestamp DESC
LIMIT ?
""",
(f"%{symbol}%", f"%{symbol}%", f"%{symbol}%", symbol, limit_events * 3),
).fetchall()
normalized_events = []
seen_event_ids: set[str] = set()
for row in event_rows:
payload = json.loads(row["payload_json"]) if row["payload_json"] else {}
content = str(row["content"] or payload.get("content") or "")
title = str(row["title"] or payload.get("title") or "")
if symbol not in f"{title} {content}".upper() and str(row["ticker"] or "").upper() != symbol:
continue
event_id = row["id"]
if event_id in seen_event_ids:
continue
seen_event_ids.add(event_id)
normalized_events.append(
{
"id": event_id,
"type": "mention",
"timestamp": row["timestamp"],
"title": title or f"{row['agent_name'] or '未知角色'}提及 {symbol}",
"meta": payload.get("conferenceTitle")
or payload.get("feedType")
or row["event_type"],
"body": content,
"tone": "neutral",
"agent": row["agent_name"] or payload.get("agentName") or payload.get("agent"),
},
)
if len(normalized_events) >= limit_events:
break
normalized_trades = [
{
"id": row["id"],
"type": "trade",
"timestamp": row["timestamp"],
"title": f"{row['side']} {int(row['qty'] or 0)}",
"meta": "交易执行",
"body": f"成交价 ${float(row['price'] or 0):.2f}",
"tone": "positive" if row["side"] == "LONG" else "negative" if row["side"] == "SHORT" else "neutral",
}
for row in trade_rows
]
normalized_signals = [
{
"id": row["id"],
"type": "signal",
"timestamp": f"{row['trade_date']}T08:00:00" if row["trade_date"] else row["created_at"],
"title": f"{row['agent_name']} 给出{row['signal'] or '中性'}信号",
"meta": row["role"],
"body": (
f"后验收益 {float(row['real_return']) * 100:+.2f}%"
if row["real_return"] is not None
else "该信号暂未完成后验评估"
),
"tone": "positive" if str(row["signal"] or "").lower() in {"bullish", "buy", "long"} else "negative" if str(row["signal"] or "").lower() in {"bearish", "sell", "short"} else "neutral",
}
for row in signal_rows
]
return {
"events": normalized_events,
"trades": normalized_trades,
"signals": normalized_signals,
}

View File

@@ -10,6 +10,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from .runtime_db import RuntimeDb
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,6 +63,7 @@ class StorageService:
self.state_dir = self.dashboard_dir.parent / "state" self.state_dir = self.dashboard_dir.parent / "state"
self.state_dir.mkdir(parents=True, exist_ok=True) self.state_dir.mkdir(parents=True, exist_ok=True)
self.server_state_file = self.state_dir / "server_state.json" self.server_state_file = self.state_dir / "server_state.json"
self.runtime_db = RuntimeDb(self.state_dir / "runtime.db")
# Feed history (for agent messages) # Feed history (for agent messages)
self.max_feed_history = 200 self.max_feed_history = 200
@@ -114,6 +117,11 @@ class StorageService:
try: try:
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False) json.dump(data, f, indent=2, ensure_ascii=False)
if file_type == "leaderboard" and isinstance(data, list):
self.runtime_db.replace_signals_for_leaderboard(data)
elif file_type == "trades" and isinstance(data, list):
for trade in data:
self.runtime_db.upsert_trade(trade)
except Exception as e: except Exception as e:
logger.error(f"Failed to save {file_type}.json: {e}") logger.error(f"Failed to save {file_type}.json: {e}")
@@ -211,6 +219,7 @@ class StorageService:
try: try:
with open(self.internal_state_file, "w", encoding="utf-8") as f: with open(self.internal_state_file, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, ensure_ascii=False) json.dump(state, f, indent=2, ensure_ascii=False)
self._sync_price_history_to_db(state.get("price_history", {}))
except Exception as e: except Exception as e:
logger.error(f"Failed to save internal state: {e}") logger.error(f"Failed to save internal state: {e}")
@@ -231,6 +240,41 @@ class StorageService:
"margin_requirement": 0.25, # Default 25% margin requirement "margin_requirement": 0.25, # Default 25% margin requirement
} }
@staticmethod
def _portfolio_is_pristine(portfolio_state: Dict[str, Any]) -> bool:
"""Return whether the persisted portfolio can be safely rebased."""
positions = portfolio_state.get("positions", {})
has_positions = any(
position.get("long", 0) or position.get("short", 0)
for position in positions.values()
)
margin_used = float(portfolio_state.get("margin_used", 0.0) or 0.0)
return not has_positions and margin_used == 0.0
def can_apply_initial_cash(self) -> bool:
"""Only allow initial cash changes before the run has traded."""
state = self.load_internal_state()
if not self._portfolio_is_pristine(state.get("portfolio_state", {})):
return False
if state.get("all_trades"):
return False
return len(state.get("equity_history", [])) <= 1
def apply_initial_cash(self, initial_cash: float) -> bool:
"""Rebase storage state to a new initial cash when the run is pristine."""
if not self.can_apply_initial_cash():
return False
self.initial_cash = float(initial_cash)
if self.internal_state_file.exists():
self.internal_state_file.unlink()
self.initialize_empty_dashboard()
state = self.load_server_state()
self.update_server_state_from_dashboard(state)
self.save_server_state(state)
return True
def save_portfolio_state(self, portfolio: Dict[str, Any]): def save_portfolio_state(self, portfolio: Dict[str, Any]):
""" """
Save portfolio state to internal state Save portfolio state to internal state
@@ -750,6 +794,7 @@ class StorageService:
"last_day_history": [], "last_day_history": [],
"trading_days_total": 0, "trading_days_total": 0,
"trading_days_completed": 0, "trading_days_completed": 0,
"price_history": {},
} }
if not self.server_state_file.exists(): if not self.server_state_file.exists():
@@ -771,6 +816,11 @@ class StorageService:
) )
logger.info(f"Trades: {len(saved_state.get('trades', []))} records") logger.info(f"Trades: {len(saved_state.get('trades', []))} records")
for event in saved_state.get("feed_history", []):
self.runtime_db.insert_event(event)
for trade in saved_state.get("trades", []):
self.runtime_db.upsert_trade(trade)
return saved_state return saved_state
def save_server_state(self, state: Dict[str, Any]): def save_server_state(self, state: Dict[str, Any]):
@@ -852,6 +902,7 @@ class StorageService:
state["feed_history"] = [] state["feed_history"] = []
state["feed_history"].insert(0, feed_msg) state["feed_history"].insert(0, feed_msg)
self.runtime_db.insert_event(feed_msg)
# Trim to max size # Trim to max size
if len(state["feed_history"]) > self.max_feed_history: if len(state["feed_history"]) > self.max_feed_history:
@@ -861,6 +912,69 @@ class StorageService:
return True return True
def record_price_point(
self,
*,
ticker: str,
timestamp: str,
price: float,
open_price: Optional[float] = None,
ret: Optional[float] = None,
source: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
):
"""Persist a runtime price point for later query-oriented reads."""
if not ticker or not timestamp:
return
try:
self.runtime_db.insert_price_point(
ticker=ticker,
timestamp=timestamp,
price=price,
open_price=open_price,
ret=ret,
source=source,
meta=meta,
)
except Exception as exc:
logger.warning("Failed to record price point for %s: %s", ticker, exc)
def _sync_price_history_to_db(self, price_history: Dict[str, Any]):
"""Backfill structured price points from serialized internal state."""
if not isinstance(price_history, dict):
return
for ticker, points in price_history.items():
if not ticker or not isinstance(points, list):
continue
for point in points:
if isinstance(point, (list, tuple)) and len(point) >= 2:
timestamp, price = point[0], point[1]
try:
self.record_price_point(
ticker=str(ticker),
timestamp=str(timestamp),
price=float(price),
)
except (TypeError, ValueError):
continue
elif isinstance(point, dict):
timestamp = point.get("timestamp") or point.get("label") or point.get("date")
price = point.get("price") or point.get("close") or point.get("value")
if not timestamp or price is None:
continue
try:
self.record_price_point(
ticker=str(ticker),
timestamp=str(timestamp),
price=float(price),
open_price=point.get("open"),
ret=point.get("ret"),
source=point.get("source"),
meta=point,
)
except (TypeError, ValueError):
continue
def _get_default_stats(self) -> Dict[str, Any]: def _get_default_stats(self) -> Dict[str, Any]:
"""Get default stats structure""" """Get default stats structure"""
return { return {
@@ -889,6 +1003,7 @@ class StorageService:
stats = self.load_file("stats") or self._get_default_stats() stats = self.load_file("stats") or self._get_default_stats()
trades = self.load_file("trades") or [] trades = self.load_file("trades") or []
leaderboard = self.load_file("leaderboard") or [] leaderboard = self.load_file("leaderboard") or []
internal_state = self.load_internal_state()
# Update state # Update state
state["portfolio"] = { state["portfolio"] = {
@@ -910,6 +1025,9 @@ class StorageService:
state["stats"] = stats state["stats"] = stats
state["trades"] = trades state["trades"] = trades
state["leaderboard"] = leaderboard state["leaderboard"] = leaderboard
state["price_history"] = internal_state.get("price_history", {})
self.runtime_db.replace_signals_for_leaderboard(leaderboard)
self._sync_price_history_to_db(state["price_history"])
# ========== Live Returns Tracking ========== # ========== Live Returns Tracking ==========

View File

@@ -0,0 +1,21 @@
---
name: fundamental_review
description: Review a company from a fundamentals-first perspective before issuing a trading signal.
---
# Fundamental Review
Use this skill when the task requires judging business quality, balance-sheet strength, profitability, or long-term earnings durability.
## Workflow
1. Check profitability, growth, financial health, and efficiency before forming a conclusion.
2. Separate durable business quality from short-term noise.
3. State what would invalidate the thesis.
4. End with a clear signal, confidence, and the main drivers behind that signal.
## Guardrails
- Do not rely on one metric in isolation.
- Call out missing data explicitly.
- Prefer conservative conclusions when financial quality is mixed.

View File

@@ -0,0 +1,21 @@
---
name: portfolio_decisioning
description: Synthesize analyst inputs and risk feedback into explicit portfolio decisions.
---
# Portfolio Decisioning
Use this skill when you are responsible for converting team analysis into final trades.
## Workflow
1. Read analyst conclusions and risk warnings before acting.
2. Evaluate the current portfolio, cash, and margin constraints.
3. Record one explicit decision per ticker using the decision tool.
4. Summarize the portfolio-level rationale after all decisions are recorded.
## Guardrails
- Position sizing must respect capital and margin limits.
- Prefer smaller size when analyst conviction and risk signals disagree.
- Do not leave a ticker undecided when the task expects a full slate of decisions.

View File

@@ -0,0 +1,21 @@
---
name: risk_review
description: Assess portfolio and market risks before final position sizing and execution.
---
# Risk Review
Use this skill when you must identify concentration, volatility, leverage, and scenario risks.
## Workflow
1. Review the proposed exposure by ticker and theme.
2. Identify concentration, volatility, liquidity, and leverage concerns.
3. Rank warnings by severity.
4. Translate risk findings into concrete limits or cautions for the portfolio manager.
## Guardrails
- Focus on actionable risk controls.
- Quantify limits when the available data supports it.
- Distinguish fatal blockers from manageable risks.

View File

@@ -0,0 +1,21 @@
---
name: sentiment_review
description: Analyze news flow, market psychology, and insider behavior for catalyst-driven signals.
---
# Sentiment Review
Use this skill when the task depends on recent catalysts, news tone, or behavioral market signals.
## Workflow
1. Review recent news and identify the dominant narrative.
2. Check insider activity for confirming or conflicting signals.
3. Separate durable sentiment shifts from transient noise.
4. Explain how sentiment changes the near-term trade outlook.
## Guardrails
- Do not confuse attention with conviction.
- Highlight when sentiment is strong but unsupported by fundamentals.
- Be explicit about catalyst timing risk.

View File

@@ -0,0 +1,21 @@
---
name: technical_review
description: Evaluate price action, momentum, and volatility to judge timing and market regime.
---
# Technical Review
Use this skill when the task is sensitive to entry timing, trend quality, or short-term market structure.
## Workflow
1. Assess trend direction and strength.
2. Check momentum and mean-reversion conditions.
3. Review volatility before making aggressive recommendations.
4. Convert the setup into a trading view with explicit risk awareness.
## Guardrails
- Distinguish trend continuation from overshoot.
- Avoid strong conviction when signals conflict.
- Treat volatility as a sizing input, not only a directional input.

View File

@@ -0,0 +1,21 @@
---
name: valuation_review
description: Estimate fair value and margin of safety using multiple valuation lenses.
---
# Valuation Review
Use this skill when the task requires determining whether a stock is cheap, expensive, or fairly priced.
## Workflow
1. Use more than one valuation method when possible.
2. Compare intrinsic value estimates with current market pricing.
3. Explain the key assumptions behind the valuation view.
4. State the margin of safety and what could compress or expand it.
## Guardrails
- Treat valuation as a range, not a single precise number.
- Call out assumption sensitivity.
- Avoid high-confidence calls when inputs are sparse or unstable.

View File

@@ -0,0 +1 @@

View File

@@ -7,6 +7,7 @@ Returns human-readable text format for easy LLM consumption.
""" """
# flake8: noqa: E501 # flake8: noqa: E501
# pylint: disable=C0301,W0613 # pylint: disable=C0301,W0613
import ast
import json import json
import logging import logging
import traceback import traceback
@@ -20,6 +21,7 @@ import pandas as pd
from agentscope.message import TextBlock from agentscope.message import TextBlock
from agentscope.tool import ToolResponse from agentscope.tool import ToolResponse
from backend.data.provider_utils import normalize_symbol
from backend.tools.data_tools import ( from backend.tools.data_tools import (
get_company_news, get_company_news,
get_financial_metrics, get_financial_metrics,
@@ -53,6 +55,16 @@ def _parse_tickers(tickers: Union[str, List[str], None]) -> List[str]:
Returns: Returns:
List of stock tickers. List of stock tickers.
""" """
def _sanitize(values: List[object]) -> List[str]:
cleaned: List[str] = []
for value in values:
if value is None:
continue
symbol = normalize_symbol(str(value).strip().strip("\"'"))
if symbol and symbol not in cleaned:
cleaned.append(symbol)
return cleaned
if tickers is None: if tickers is None:
return [] return []
@@ -60,17 +72,22 @@ def _parse_tickers(tickers: Union[str, List[str], None]) -> List[str]:
try: try:
parsed = json.loads(tickers) parsed = json.loads(tickers)
if isinstance(parsed, list): if isinstance(parsed, list):
return parsed return _sanitize(parsed)
# If it's a single string, wrap in list return _sanitize([parsed])
return [parsed]
except json.JSONDecodeError: except json.JSONDecodeError:
# If not valid JSON, treat as comma-separated string try:
return [t.strip() for t in tickers.split(",") if t.strip()] parsed = ast.literal_eval(tickers)
if isinstance(parsed, list):
return _sanitize(parsed)
return _sanitize([parsed])
except (SyntaxError, ValueError):
pass
return _sanitize(tickers.split(","))
if isinstance(tickers, list): if isinstance(tickers, list):
return tickers return _sanitize(tickers)
return [] return _sanitize([tickers])
def _safe_float(value, default=0.0) -> float: def _safe_float(value, default=0.0) -> float:
@@ -350,6 +367,7 @@ def get_financial_metrics_tool(
""" """
current_date = _resolved_date(current_date) current_date = _resolved_date(current_date)
tickers = _parse_tickers(tickers)
lines = [ lines = [
f"=== Comprehensive Financial Metrics ({current_date}, {period}) ===\n", f"=== Comprehensive Financial Metrics ({current_date}, {period}) ===\n",
] ]

View File

@@ -96,13 +96,19 @@ def get_prices(
list[Price]: List of Price objects list[Price]: List of Price objects
""" """
ticker = normalize_symbol(ticker) ticker = normalize_symbol(ticker)
if not ticker:
return []
cached_sources = _router.price_sources() cached_sources = _router.price_sources()
for source in cached_sources: for source in cached_sources:
cache_key = f"{ticker}_{start_date}_{end_date}_{source}" cache_key = f"{ticker}_{start_date}_{end_date}_{source}"
if cached_data := _cache.get_prices(cache_key): if cached_data := _cache.get_prices(cache_key):
return [Price(**price) for price in cached_data] return [Price(**price) for price in cached_data]
try:
prices, data_source = _router.get_prices(ticker, start_date, end_date) prices, data_source = _router.get_prices(ticker, start_date, end_date)
except Exception as exc:
logger.info("Price lookup failed for %s: %s", ticker, exc)
return []
if not prices: if not prices:
return [] return []
@@ -133,17 +139,23 @@ def get_financial_metrics(
list[FinancialMetrics]: List of financial metrics list[FinancialMetrics]: List of financial metrics
""" """
ticker = normalize_symbol(ticker) ticker = normalize_symbol(ticker)
if not ticker:
return []
for source in _router.api_sources(): for source in _router.api_sources():
cache_key = f"{ticker}_{period}_{end_date}_{limit}_{source}" cache_key = f"{ticker}_{period}_{end_date}_{limit}_{source}"
if cached_data := _cache.get_financial_metrics(cache_key): if cached_data := _cache.get_financial_metrics(cache_key):
return [FinancialMetrics(**metric) for metric in cached_data] return [FinancialMetrics(**metric) for metric in cached_data]
try:
financial_metrics, data_source = _router.get_financial_metrics( financial_metrics, data_source = _router.get_financial_metrics(
ticker=ticker, ticker=ticker,
end_date=end_date, end_date=end_date,
period=period, period=period,
limit=limit, limit=limit,
) )
except Exception as exc:
logger.info("Financial metrics lookup failed for %s: %s", ticker, exc)
return []
if not financial_metrics: if not financial_metrics:
return [] return []
@@ -169,6 +181,8 @@ def search_line_items(
""" """
try: try:
ticker = normalize_symbol(ticker) ticker = normalize_symbol(ticker)
if not ticker:
return []
return _router.search_line_items( return _router.search_line_items(
ticker=ticker, ticker=ticker,
line_items=line_items, line_items=line_items,
@@ -190,6 +204,8 @@ def get_insider_trades(
) -> list[InsiderTrade]: ) -> list[InsiderTrade]:
"""Fetch insider trades from cache or API.""" """Fetch insider trades from cache or API."""
ticker = normalize_symbol(ticker) ticker = normalize_symbol(ticker)
if not ticker:
return []
for source in _router.api_sources(): for source in _router.api_sources():
cache_key = ( cache_key = (
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{source}" f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{source}"
@@ -197,12 +213,16 @@ def get_insider_trades(
if cached_data := _cache.get_insider_trades(cache_key): if cached_data := _cache.get_insider_trades(cache_key):
return [InsiderTrade(**trade) for trade in cached_data] return [InsiderTrade(**trade) for trade in cached_data]
try:
all_trades, data_source = _router.get_insider_trades( all_trades, data_source = _router.get_insider_trades(
ticker=ticker, ticker=ticker,
end_date=end_date, end_date=end_date,
start_date=start_date, start_date=start_date,
limit=limit, limit=limit,
) )
except Exception as exc:
logger.info("Insider trades lookup failed for %s: %s", ticker, exc)
return []
if not all_trades: if not all_trades:
return [] return []
@@ -219,6 +239,8 @@ def get_company_news(
) -> list[CompanyNews]: ) -> list[CompanyNews]:
"""Fetch company news from cache or API.""" """Fetch company news from cache or API."""
ticker = normalize_symbol(ticker) ticker = normalize_symbol(ticker)
if not ticker:
return []
for source in _router.api_sources(): for source in _router.api_sources():
cache_key = ( cache_key = (
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{source}" f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{source}"
@@ -226,12 +248,16 @@ def get_company_news(
if cached_data := _cache.get_company_news(cache_key): if cached_data := _cache.get_company_news(cache_key):
return [CompanyNews(**news) for news in cached_data] return [CompanyNews(**news) for news in cached_data]
try:
all_news, data_source = _router.get_company_news( all_news, data_source = _router.get_company_news(
ticker=ticker, ticker=ticker,
end_date=end_date, end_date=end_date,
start_date=start_date, start_date=start_date,
limit=limit, limit=limit,
) )
except Exception as exc:
logger.info("Company news lookup failed for %s: %s", ticker, exc)
return []
if not all_news: if not all_news:
return [] return []
@@ -243,6 +269,8 @@ def get_company_news(
def get_market_cap(ticker: str, end_date: str) -> float | None: def get_market_cap(ticker: str, end_date: str) -> float | None:
"""Fetch market cap from the API. Finnhub values are converted from millions.""" """Fetch market cap from the API. Finnhub values are converted from millions."""
ticker = normalize_symbol(ticker) ticker = normalize_symbol(ticker)
if not ticker:
return None
def _metrics_lookup(symbol: str, date: str): def _metrics_lookup(symbol: str, date: str):
for source in _router.api_sources(): for source in _router.api_sources():
@@ -256,11 +284,15 @@ def get_market_cap(ticker: str, end_date: str) -> float | None:
limit=10, limit=10,
) )
try:
market_cap, _ = _router.get_market_cap( market_cap, _ = _router.get_market_cap(
ticker=ticker, ticker=ticker,
end_date=end_date, end_date=end_date,
metrics_lookup=_metrics_lookup, metrics_lookup=_metrics_lookup,
) )
except Exception as exc:
logger.info("Market cap lookup failed for %s: %s", ticker, exc)
return None
return market_cap return market_cap

218
backend/tools/risk_tools.py Normal file
View File

@@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
"""Risk management tools for the risk manager agent."""
import json
from typing import Any, Dict, Iterable, List
from agentscope.message import TextBlock
from agentscope.tool import ToolResponse
def _to_text_response(text: str) -> ToolResponse:
return ToolResponse(content=[TextBlock(type="text", text=text)])
def _parse_object(payload: Any) -> Dict[str, Any]:
if payload is None:
return {}
if isinstance(payload, dict):
return payload
if isinstance(payload, str):
try:
parsed = json.loads(payload)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _parse_prices(payload: Any) -> Dict[str, float]:
parsed = _parse_object(payload)
prices = {}
for ticker, value in parsed.items():
try:
prices[str(ticker)] = float(value)
except (TypeError, ValueError):
continue
return prices
def _iter_positions(
portfolio: Dict[str, Any],
prices: Dict[str, float],
) -> Iterable[Dict[str, Any]]:
positions = portfolio.get("positions", {})
for ticker, raw_position in positions.items():
if not isinstance(raw_position, dict):
continue
price = prices.get(ticker, 0.0)
long_qty = int(raw_position.get("long", 0) or 0)
short_qty = int(raw_position.get("short", 0) or 0)
long_value = long_qty * price
short_value = short_qty * price
net_value = long_value - short_value
gross_value = long_value + short_value
yield {
"ticker": ticker,
"price": price,
"long_qty": long_qty,
"short_qty": short_qty,
"long_value": long_value,
"short_value": short_value,
"net_value": net_value,
"gross_value": gross_value,
}
def _portfolio_equity(portfolio: Dict[str, Any], prices: Dict[str, float]) -> float:
cash = float(portfolio.get("cash", 0.0) or 0.0)
margin_used = float(portfolio.get("margin_used", 0.0) or 0.0)
total = cash + margin_used
for position in _iter_positions(portfolio, prices):
total += position["net_value"]
return total
def assess_position_concentration(
portfolio: Dict[str, Any] | str,
current_prices: Dict[str, float] | str,
) -> ToolResponse:
"""
Assess single-name concentration and gross exposure in the current portfolio.
Args:
portfolio: Portfolio state with cash, positions, and margin fields.
current_prices: Current price map by ticker.
"""
portfolio_obj = _parse_object(portfolio)
prices = _parse_prices(current_prices)
equity = _portfolio_equity(portfolio_obj, prices)
if equity <= 0:
return _to_text_response("Unable to assess concentration: portfolio equity is non-positive.")
exposures: List[Dict[str, Any]] = sorted(
_iter_positions(portfolio_obj, prices),
key=lambda item: abs(item["net_value"]),
reverse=True,
)
if not exposures:
return _to_text_response(
"No open positions. Concentration risk is low because the portfolio is fully in cash."
)
lines = ["=== Position Concentration Assessment ==="]
gross_exposure = sum(item["gross_value"] for item in exposures)
net_exposure = sum(item["net_value"] for item in exposures)
lines.append(f"Portfolio equity: ${equity:,.2f}")
lines.append(f"Gross exposure: ${gross_exposure:,.2f} ({gross_exposure / equity:.1%} of equity)")
lines.append(f"Net exposure: ${net_exposure:,.2f} ({net_exposure / equity:.1%} of equity)")
lines.append("Largest positions by net exposure:")
for item in exposures[:5]:
weight = item["net_value"] / equity
gross_weight = item["gross_value"] / equity
direction = "NET LONG" if item["net_value"] >= 0 else "NET SHORT"
lines.append(
f"- {item['ticker']}: {direction}, net ${item['net_value']:,.2f} ({weight:.1%}), "
f"gross ${item['gross_value']:,.2f} ({gross_weight:.1%})"
)
top_weight = abs(exposures[0]["net_value"]) / equity
if top_weight >= 0.30:
lines.append("Risk flag: concentration is HIGH because the largest single-name exposure exceeds 30% of equity.")
elif top_weight >= 0.20:
lines.append("Risk flag: concentration is MODERATE because the largest single-name exposure exceeds 20% of equity.")
else:
lines.append("Risk flag: concentration is currently contained at the single-name level.")
return _to_text_response("\n".join(lines))
def assess_margin_and_liquidity(
portfolio: Dict[str, Any] | str,
current_prices: Dict[str, float] | str,
) -> ToolResponse:
"""
Assess available cash, margin usage, and short exposure pressure.
Args:
portfolio: Portfolio state with cash, positions, and margin fields.
current_prices: Current price map by ticker.
"""
portfolio_obj = _parse_object(portfolio)
prices = _parse_prices(current_prices)
equity = _portfolio_equity(portfolio_obj, prices)
cash = float(portfolio_obj.get("cash", 0.0) or 0.0)
margin_used = float(portfolio_obj.get("margin_used", 0.0) or 0.0)
margin_requirement = float(portfolio_obj.get("margin_requirement", 0.0) or 0.0)
short_exposure = sum(item["short_value"] for item in _iter_positions(portfolio_obj, prices))
margin_buffer = cash - margin_used
lines = ["=== Margin And Liquidity Assessment ==="]
lines.append(f"Portfolio equity: ${equity:,.2f}")
lines.append(f"Cash available: ${cash:,.2f}")
lines.append(f"Margin used: ${margin_used:,.2f}")
lines.append(f"Margin requirement: {margin_requirement:.1%}")
lines.append(f"Short exposure: ${short_exposure:,.2f}")
lines.append(f"Margin buffer (cash - used): ${margin_buffer:,.2f}")
if equity > 0:
lines.append(f"Margin used / equity: {margin_used / equity:.1%}")
lines.append(f"Short exposure / equity: {short_exposure / equity:.1%}")
if margin_buffer < 0:
lines.append("Risk flag: HIGH. Margin usage exceeds available cash buffer.")
elif equity > 0 and margin_used / equity > 0.35:
lines.append("Risk flag: MODERATE to HIGH. Margin usage is above 35% of equity.")
else:
lines.append("Risk flag: margin pressure is currently manageable.")
return _to_text_response("\n".join(lines))
def assess_volatility_exposure(
tickers: List[str] | str,
current_date: str | None = None,
) -> ToolResponse:
"""
Assess per-ticker volatility and risk level for the current watchlist.
Args:
tickers: List of stock tickers or JSON list string.
current_date: Analysis date in YYYY-MM-DD format.
"""
from datetime import datetime, timedelta
from backend.tools.analysis_tools import _parse_tickers, _resolved_date
from backend.tools.data_tools import get_prices, prices_to_df
from backend.tools.technical_signals import StockTechnicalAnalyzer
tickers_list = _parse_tickers(tickers)
current_date = _resolved_date(current_date)
end_dt = datetime.strptime(current_date, "%Y-%m-%d")
start_date = (end_dt - timedelta(days=90)).strftime("%Y-%m-%d")
analyzer = StockTechnicalAnalyzer()
lines = [f"=== Volatility Exposure Assessment ({current_date}) ==="]
for ticker in tickers_list:
prices = get_prices(
ticker=ticker,
start_date=start_date,
end_date=current_date,
)
if not prices or len(prices) < 5:
lines.append(f"- {ticker}: insufficient price data")
continue
signal = analyzer.analyze(ticker=ticker, df=prices_to_df(prices))
lines.append(
f"- {ticker}: annualized volatility {signal.annualized_volatility_pct:.1f}%, "
f"RSI14 {signal.rsi14:.1f}, trend {signal.trend}, risk level {signal.risk_level}"
)
if len(lines) == 1:
lines.append("No tickers provided.")
return _to_text_response("\n".join(lines))

View File

@@ -1,7 +1,6 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<script src="https://cdn.tailwindcss.com"></script>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/trading_logo.png" /> <link rel="icon" type="image/png" href="/trading_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -19,9 +19,9 @@ import AgentFeed from './components/AgentFeed';
import StockLogo from './components/StockLogo'; import StockLogo from './components/StockLogo';
import StatisticsView from './components/StatisticsView'; import StatisticsView from './components/StatisticsView';
import PerformanceView from './components/PerformanceView'; import PerformanceView from './components/PerformanceView';
import AboutModal from './components/AboutModal'; import StockExplainView from './components/StockExplainView.jsx';
import RulesView from './components/RulesView';
import Header from './components/Header.jsx'; import Header from './components/Header.jsx';
import WatchlistPanel from './components/WatchlistPanel.jsx';
// Utils // Utils
import { formatNumber, formatTickerPrice } from './utils/formatters'; import { formatNumber, formatTickerPrice } from './utils/formatters';
@@ -39,9 +39,8 @@ export default function LiveTradingApp() {
const [currentDate, setCurrentDate] = useState(null); const [currentDate, setCurrentDate] = useState(null);
const [progress, setProgress] = useState({ current: 0, total: 0 }); const [progress, setProgress] = useState({ current: 0, total: 0 });
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
const [showAboutModal, setShowAboutModal] = useState(false);
// View toggle: 'rules' | 'room' | 'chart' | 'statistics' // View toggle: 'room' | 'explain' | 'chart' | 'statistics'
const [currentView, setCurrentView] = useState('chart'); // Start with chart, then animate to room const [currentView, setCurrentView] = useState('chart'); // Start with chart, then animate to room
const [isInitialAnimating, setIsInitialAnimating] = useState(true); const [isInitialAnimating, setIsInitialAnimating] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date()); const [lastUpdate, setLastUpdate] = useState(new Date());
@@ -71,6 +70,11 @@ export default function LiveTradingApp() {
// Ticker prices (now from real-time data) // Ticker prices (now from real-time data)
const [tickers, setTickers] = useState(INITIAL_TICKERS); const [tickers, setTickers] = useState(INITIAL_TICKERS);
const [rollingTickers, setRollingTickers] = useState({}); const [rollingTickers, setRollingTickers] = useState({});
const [priceHistoryByTicker, setPriceHistoryByTicker] = useState({});
const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({});
const [explainEventsByTicker, setExplainEventsByTicker] = useState({});
const [selectedExplainSymbol, setSelectedExplainSymbol] = useState('');
const [historySourceByTicker, setHistorySourceByTicker] = useState({});
// Room bubbles // Room bubbles
const [bubbles, setBubbles] = useState({}); const [bubbles, setBubbles] = useState({});
@@ -84,10 +88,18 @@ export default function LiveTradingApp() {
const [marketStatus, setMarketStatus] = useState(null); // { status, status_text, ... } const [marketStatus, setMarketStatus] = useState(null); // { status, status_text, ... }
const [virtualTime, setVirtualTime] = useState(null); // Virtual time from server (for mock mode) const [virtualTime, setVirtualTime] = useState(null); // Virtual time from server (for mock mode)
const [dataSources, setDataSources] = useState(null); const [dataSources, setDataSources] = useState(null);
const [runtimeConfig, setRuntimeConfig] = useState(null);
const [isWatchlistPanelOpen, setIsWatchlistPanelOpen] = useState(false);
const [watchlistDraftSymbols, setWatchlistDraftSymbols] = useState([]);
const [watchlistInputValue, setWatchlistInputValue] = useState('');
const [watchlistFeedback, setWatchlistFeedback] = useState(null);
const [isWatchlistSaving, setIsWatchlistSaving] = useState(false);
const clientRef = useRef(null); const clientRef = useRef(null);
const containerRef = useRef(null); const containerRef = useRef(null);
const agentFeedRef = useRef(null); const agentFeedRef = useRef(null);
const isWatchlistSavingRef = useRef(false);
const requestedStockHistoryRef = useRef(new Set());
// Track last virtual time update to calculate increment // Track last virtual time update to calculate increment
const lastVirtualTimeRef = useRef(null); const lastVirtualTimeRef = useRef(null);
@@ -96,12 +108,311 @@ export default function LiveTradingApp() {
// Last day history for replay // Last day history for replay
const [lastDayHistory, setLastDayHistory] = useState([]); const [lastDayHistory, setLastDayHistory] = useState([]);
const buildTickersFromSymbols = useCallback((symbols, previousTickers = []) => {
if (!Array.isArray(symbols) || symbols.length === 0) {
return previousTickers;
}
return symbols
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
.map((symbol) => {
const normalized = symbol.trim().toUpperCase();
const existing = previousTickers.find((ticker) => ticker.symbol === normalized);
return existing || {
symbol: normalized,
price: null,
change: null
};
});
}, []);
const normalizePriceHistory = useCallback((payload) => {
if (!payload || typeof payload !== 'object') {
return {};
}
const normalized = {};
Object.entries(payload).forEach(([symbol, points]) => {
const ticker = String(symbol || '').trim().toUpperCase();
if (!ticker || !Array.isArray(points)) {
return;
}
normalized[ticker] = points
.map((point) => {
if (Array.isArray(point) && point.length >= 2) {
const [label, value] = point;
const price = Number(value);
if (!label || !Number.isFinite(price)) return null;
return {
timestamp: String(label),
label: String(label),
price
};
}
if (point && typeof point === 'object') {
const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label;
const price = Number(point.price ?? point.v ?? point.value ?? point.close);
if (!rawTimestamp || !Number.isFinite(price)) return null;
return {
timestamp: String(rawTimestamp),
label: String(rawTimestamp),
price
};
}
return null;
})
.filter(Boolean)
.slice(-120);
});
return normalized;
}, []);
// Determine if LIVE tab should be enabled // Determine if LIVE tab should be enabled
const isLiveEnabled = useMemo(() => { const isLiveEnabled = useMemo(() => {
if (!marketStatus) return false; if (!marketStatus) return false;
return marketStatus.status === 'open'; return marketStatus.status === 'open';
}, [marketStatus]); }, [marketStatus]);
const displayTickers = useMemo(() => {
const symbols = runtimeConfig?.tickers;
if (Array.isArray(symbols) && symbols.length > 0) {
return buildTickersFromSymbols(symbols, tickers);
}
return tickers;
}, [buildTickersFromSymbols, runtimeConfig, tickers]);
const runtimeWatchlistSymbols = useMemo(() => {
const symbols = runtimeConfig?.tickers;
if (Array.isArray(symbols) && symbols.length > 0) {
return symbols
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
.map((symbol) => symbol.trim().toUpperCase());
}
return displayTickers
.map((ticker) => ticker.symbol)
.filter((symbol) => typeof symbol === 'string' && symbol.trim());
}, [displayTickers, runtimeConfig]);
useEffect(() => {
const symbols = displayTickers
.map((ticker) => ticker.symbol)
.filter((symbol) => typeof symbol === 'string' && symbol.trim());
if (!symbols.length) {
setSelectedExplainSymbol('');
return;
}
if (!selectedExplainSymbol || !symbols.includes(selectedExplainSymbol)) {
setSelectedExplainSymbol(symbols[0]);
}
}, [displayTickers, selectedExplainSymbol]);
const watchlistSuggestions = useMemo(
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
[]
);
const isWatchlistDraftDirty = useMemo(() => {
if (watchlistInputValue.trim()) {
return true;
}
if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
return true;
}
return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
}, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
const marketStatusLabel = useMemo(() => {
if (!marketStatus) {
return null;
}
const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : '';
const normalized = raw.toLowerCase();
if (normalized === 'market closed (non-trading day)') {
return '休市';
}
if (normalized === 'market open') {
return '开盘';
}
if (normalized === 'market closed') {
return '收盘';
}
return raw || (marketStatus.status === 'open' ? '开盘' : '收盘');
}, [marketStatus]);
const priceSourceLabel = useMemo(() => {
const source = dataSources?.last_success?.prices;
if (!source) {
return null;
}
const normalized = String(source).trim().toLowerCase();
const labels = {
yfinance: '数据源 Yahoo',
finnhub: '数据源 Finnhub',
financial_datasets: '数据源 Financial Datasets',
local_csv: '数据源 CSV'
};
return labels[normalized] || `数据源 ${String(source).trim()}`;
}, [dataSources]);
const parseWatchlistInput = useCallback((value) => {
if (typeof value !== 'string') {
return [];
}
return Array.from(
new Set(
value
.split(/[\s,]+/)
.map((symbol) => symbol.trim().toUpperCase())
.filter(Boolean)
)
);
}, []);
const commitWatchlistInput = useCallback((value) => {
const parsed = parseWatchlistInput(value);
if (parsed.length === 0) {
return [];
}
setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
setWatchlistInputValue('');
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
return parsed;
}, [parseWatchlistInput, watchlistFeedback]);
const handleWatchlistRemove = useCallback((symbolToRemove) => {
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback]);
const handleWatchlistPanelToggle = useCallback(() => {
setIsWatchlistPanelOpen((open) => {
const nextOpen = !open;
if (nextOpen) {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}
return nextOpen;
});
}, [runtimeWatchlistSymbols]);
const handleWatchlistInputChange = useCallback((value) => {
setWatchlistInputValue(value);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback]);
const handleWatchlistInputKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitWatchlistInput(watchlistInputValue);
}
}, [commitWatchlistInput, watchlistInputValue]);
const handleWatchlistSuggestionClick = useCallback((symbol) => {
if (watchlistDraftSymbols.includes(symbol)) {
return;
}
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistDraftSymbols, watchlistFeedback]);
const handleWatchlistRestoreCurrent = useCallback(() => {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}, [runtimeWatchlistSymbols]);
const handleWatchlistRestoreDefault = useCallback(() => {
setWatchlistDraftSymbols(watchlistSuggestions);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}, [watchlistSuggestions]);
const handleWatchlistSave = useCallback(() => {
const pendingTickers = parseWatchlistInput(watchlistInputValue);
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
if (nextTickers.length === 0) {
setWatchlistFeedback({ type: 'error', text: '至少输入 1 个有效股票代码' });
return;
}
if (!clientRef.current) {
setWatchlistFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setIsWatchlistSaving(true);
setWatchlistFeedback(null);
setWatchlistDraftSymbols(nextTickers);
setWatchlistInputValue('');
const success = clientRef.current.send({
type: 'update_watchlist',
tickers: nextTickers
});
if (!success) {
setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue]);
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
if (!force && requestedStockHistoryRef.current.has(normalized)) {
return false;
}
const success = clientRef.current.send({
type: 'get_stock_history',
ticker: normalized,
lookback_days: 120
});
if (success) {
requestedStockHistoryRef.current.add(normalized);
}
return success;
}, []);
const requestStockExplainEvents = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_explain_events',
ticker: normalized
});
}, []);
// Switch away from LIVE tab when market closes // Switch away from LIVE tab when market closes
useEffect(() => { useEffect(() => {
if (!isLiveEnabled && chartTab === 'live') { if (!isLiveEnabled && chartTab === 'live') {
@@ -109,6 +420,27 @@ export default function LiveTradingApp() {
} }
}, [isLiveEnabled, chartTab]); }, [isLiveEnabled, chartTab]);
useEffect(() => {
if (!isWatchlistPanelOpen || !isWatchlistDraftDirty) {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
if (!isWatchlistPanelOpen) {
setWatchlistInputValue('');
}
}
}, [isWatchlistDraftDirty, isWatchlistPanelOpen, runtimeWatchlistSymbols]);
useEffect(() => {
isWatchlistSavingRef.current = isWatchlistSaving;
}, [isWatchlistSaving]);
useEffect(() => {
if (currentView !== 'explain' || !selectedExplainSymbol) {
return;
}
requestStockHistory(selectedExplainSymbol);
requestStockExplainEvents(selectedExplainSymbol);
}, [currentView, requestStockExplainEvents, requestStockHistory, selectedExplainSymbol]);
// Clock - use virtual time if available (for mock mode) // Clock - use virtual time if available (for mock mode)
useEffect(() => { useEffect(() => {
if (virtualTime) { if (virtualTime) {
@@ -253,6 +585,10 @@ export default function LiveTradingApp() {
// Error response (for fast forward errors) // Error response (for fast forward errors)
error: (e) => { error: (e) => {
console.error('[Error]', e.message); console.error('[Error]', e.message);
if (isWatchlistSavingRef.current) {
setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: e.message || '更新 watchlist 失败' });
}
// Handle fast forward errors // Handle fast forward errors
if (e.message && e.message.includes('fast forward')) { if (e.message && e.message.includes('fast forward')) {
@@ -307,6 +643,12 @@ export default function LiveTradingApp() {
if (state.data_sources) { if (state.data_sources) {
setDataSources(state.data_sources); setDataSources(state.data_sources);
} }
if (state.runtime_config) {
setRuntimeConfig(state.runtime_config);
}
if (Array.isArray(state.tickers) && state.tickers.length > 0) {
setTickers(prevTickers => buildTickersFromSymbols(state.tickers, prevTickers));
}
// 检查是否是mock模式 // 检查是否是mock模式
const isMockMode = state.is_mock_mode === true; const isMockMode = state.is_mock_mode === true;
if (state.market_status) { if (state.market_status) {
@@ -356,6 +698,9 @@ export default function LiveTradingApp() {
if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard); if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard);
} }
if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices); if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices);
if (state.price_history) {
setPriceHistoryByTicker(normalizePriceHistory(state.price_history));
}
// Load and process historical feed data // Load and process historical feed data
if (state.feed_history && Array.isArray(state.feed_history)) { if (state.feed_history && Array.isArray(state.feed_history)) {
@@ -388,6 +733,75 @@ export default function LiveTradingApp() {
} }
}, },
runtime_assets_reloaded: (e) => {
if (e.runtime_config_applied) {
setRuntimeConfig(e.runtime_config_applied);
}
if (Array.isArray(e.runtime_config_applied?.tickers)) {
setTickers(prevTickers => buildTickersFromSymbols(e.runtime_config_applied.tickers, prevTickers));
setWatchlistDraftSymbols(e.runtime_config_applied.tickers.map((symbol) => String(symbol).trim().toUpperCase()));
setWatchlistInputValue('');
}
if (isWatchlistSavingRef.current) {
setIsWatchlistSaving(false);
}
addSystemMessage('运行时配置已热更新');
},
watchlist_updated: (e) => {
if (Array.isArray(e.tickers)) {
const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase());
requestedStockHistoryRef.current = new Set(
Array.from(requestedStockHistoryRef.current).filter((symbol) => normalizedTickers.includes(symbol))
);
setRuntimeConfig((prev) => ({
...(prev || {}),
tickers: normalizedTickers
}));
setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers));
setWatchlistDraftSymbols(normalizedTickers);
setWatchlistInputValue('');
}
setIsWatchlistSaving(false);
setWatchlistFeedback({
type: 'success',
text: `已更新为 ${Array.isArray(e.tickers) ? e.tickers.join(', ') : '最新列表'}`
});
},
stock_history_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
if (Array.isArray(e.prices)) {
setOhlcHistoryByTicker((prev) => ({
...prev,
[symbol]: e.prices
}));
setHistorySourceByTicker((prev) => ({
...prev,
[symbol]: e.source || null
}));
}
},
stock_explain_events_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
setExplainEventsByTicker((prev) => ({
...prev,
[symbol]: {
events: Array.isArray(e.events) ? e.events : [],
signals: Array.isArray(e.signals) ? e.signals : [],
trades: Array.isArray(e.trades) ? e.trades : []
}
}));
},
// Real-time price updates // Real-time price updates
price_update: (e) => { price_update: (e) => {
try { try {
@@ -402,6 +816,24 @@ export default function LiveTradingApp() {
setIsConnected(true); setIsConnected(true);
console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`); console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`);
setPriceHistoryByTicker((prev) => {
const ticker = String(symbol).trim().toUpperCase();
const nextPoint = {
timestamp: new Date().toISOString(),
label: now.toISOString(),
price: Number(price)
};
const existing = Array.isArray(prev[ticker]) ? prev[ticker] : [];
const lastPoint = existing[existing.length - 1];
if (lastPoint && Number(lastPoint.price) === Number(nextPoint.price)) {
return prev;
}
return {
...prev,
[ticker]: [...existing, nextPoint].slice(-120)
};
});
// Update ticker price with animation // Update ticker price with animation
setTickers(prevTickers => { setTickers(prevTickers => {
return prevTickers.map(ticker => { return prevTickers.map(ticker => {
@@ -714,7 +1146,7 @@ export default function LiveTradingApp() {
clientRef.current.disconnect(); clientRef.current.disconnect();
} }
}; };
}, []); // Empty dependency array - only run once on mount }, [addSystemMessage, buildTickersFromSymbols, processFeedEvent, processHistoricalFeed]); // Only reconnect if handlers change
// Resizing handlers // Resizing handlers
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
@@ -755,10 +1187,7 @@ export default function LiveTradingApp() {
{/* Header */} {/* Header */}
<div className="header"> <div className="header">
<Header <Header />
onEvoTradersClick={() => setShowAboutModal(true)}
evoTradersLinkStyle="default"
/>
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}> <div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
{/* Mock Mode Indicator */} {/* Mock Mode Indicator */}
@@ -885,21 +1314,41 @@ export default function LiveTradingApp() {
<> <>
<span className="status-sep">·</span> <span className="status-sep">·</span>
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}> <span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
{marketStatus.status_text || (marketStatus.status === 'open' ? '开盘' : '收盘')} {marketStatusLabel}
</span> </span>
</> </>
)} )}
{dataSources?.last_success?.prices && ( {priceSourceLabel && (
<> <>
<span className="status-sep">·</span> <span className="status-sep">·</span>
<span className="market-text backtest"> <span className="market-text backtest">
DATA {String(dataSources.last_success.prices).toUpperCase()} {priceSourceLabel}
</span> </span>
</> </>
)} )}
<span className="status-sep">·</span> <span className="status-sep">·</span>
<span className="time-text">{lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span> <span className="time-text">{lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
</div> </div>
<WatchlistPanel
isOpen={isWatchlistPanelOpen}
isConnected={isConnected}
isSaving={isWatchlistSaving}
draftSymbols={watchlistDraftSymbols}
inputValue={watchlistInputValue}
feedback={watchlistFeedback}
suggestions={watchlistSuggestions}
onToggle={handleWatchlistPanelToggle}
onClose={() => setIsWatchlistPanelOpen(false)}
onInputChange={handleWatchlistInputChange}
onInputKeyDown={handleWatchlistInputKeyDown}
onAdd={() => commitWatchlistInput(watchlistInputValue)}
onRemove={handleWatchlistRemove}
onRestoreCurrent={handleWatchlistRestoreCurrent}
onRestoreDefault={handleWatchlistRestoreDefault}
onSuggestionClick={handleWatchlistSuggestionClick}
onSave={handleWatchlistSave}
/>
</div> </div>
</div> </div>
@@ -910,7 +1359,7 @@ export default function LiveTradingApp() {
<div className="ticker-track"> <div className="ticker-track">
{[0, 1].map((groupIdx) => ( {[0, 1].map((groupIdx) => (
<div key={groupIdx} className="ticker-group"> <div key={groupIdx} className="ticker-group">
{tickers.map(ticker => ( {displayTickers.map(ticker => (
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item"> <div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
<StockLogo ticker={ticker.symbol} size={16} /> <StockLogo ticker={ticker.symbol} size={16} />
<span className="ticker-symbol">{ticker.symbol}</span> <span className="ticker-symbol">{ticker.symbol}</span>
@@ -947,13 +1396,6 @@ export default function LiveTradingApp() {
<div className="chart-section"> <div className="chart-section">
<div className="view-container"> <div className="view-container">
<div className="view-nav-bar"> <div className="view-nav-bar">
<button
className={`view-nav-btn ${currentView === 'rules' ? 'active' : ''}`}
onClick={() => setCurrentView('rules')}
>
规则
</button>
<button <button
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`} className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
onClick={() => setCurrentView('room')} onClick={() => setCurrentView('room')}
@@ -961,6 +1403,13 @@ export default function LiveTradingApp() {
交易室 交易室
</button> </button>
<button
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
onClick={() => setCurrentView('explain')}
>
个股解释
</button>
<button <button
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`} className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
onClick={() => setCurrentView('chart')} onClick={() => setCurrentView('chart')}
@@ -977,12 +1426,15 @@ export default function LiveTradingApp() {
</div> </div>
{/* Slider container with four views */} {/* Slider container with four views */}
<div className={`view-slider-four ${currentView === 'rules' ? 'show-rules' : currentView === 'room' ? 'show-room' : currentView === 'statistics' ? 'show-statistics' : 'show-chart'} ${!isInitialAnimating ? 'normal-speed' : ''}`}> <div className={`view-slider-four ${
{/* Rules View Panel */} currentView === 'room'
<div className="view-panel"> ? 'show-room'
<RulesView /> : currentView === 'explain'
</div> ? 'show-explain'
: currentView === 'statistics'
? 'show-statistics'
: 'show-chart'
} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
{/* Room View Panel */} {/* Room View Panel */}
<div className="view-panel"> <div className="view-panel">
<RoomView <RoomView
@@ -994,6 +1446,23 @@ export default function LiveTradingApp() {
/> />
</div> </div>
{/* Stock Explain View Panel */}
<div className="view-panel">
<StockExplainView
tickers={displayTickers}
holdings={holdings}
trades={trades}
leaderboard={leaderboard}
feed={feed}
priceHistoryByTicker={priceHistoryByTicker}
ohlcHistoryByTicker={ohlcHistoryByTicker}
selectedSymbol={selectedExplainSymbol}
onSelectedSymbolChange={setSelectedExplainSymbol}
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
/>
</div>
{/* Chart View Panel */} {/* Chart View Panel */}
<div className="view-panel"> <div className="view-panel">
<div className="chart-container"> <div className="chart-container">
@@ -1059,9 +1528,6 @@ export default function LiveTradingApp() {
</div> </div>
</div> </div>
</> </>
{/* About Modal */}
{showAboutModal && <AboutModal onClose={() => setShowAboutModal(false)} />}
</div> </div>
); );
} }

View File

@@ -1,294 +0,0 @@
import React, { useState } from 'react';
import Header from './Header.jsx';
export default function AboutModal({ onClose }) {
const [isClosing, setIsClosing] = useState(false);
const [language] = useState('zh');
const handleClose = () => {
setIsClosing(true);
// Wait for animation to complete before actually closing
setTimeout(() => {
onClose();
}, 600); // Match animation duration
};
const overlayStyle = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: '#ffffff',
zIndex: 9999,
animation: isClosing
? 'collapseUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards'
: 'expandDown 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
transformOrigin: 'top center',
overflowY: 'auto'
};
const contentStyle = {
maxWidth: '900px',
width: '90%',
margin: '0 auto',
textAlign: 'left',
fontFamily: "'IBM Plex Mono', monospace",
color: '#000000',
lineHeight: 1.8,
fontSize: '14px',
letterSpacing: '0.01em',
padding: '60px 20px 80px',
animation: isClosing
? 'fadeOutContent 0.4s ease forwards'
: 'fadeInContent 0.8s ease 0.3s backwards'
};
const highlight = {
color: '#615CED',
fontWeight: 600
};
const linkStyle = {
color: '#615CED',
textDecoration: 'none',
borderBottom: '1px solid #615CED',
transition: 'all 0.2s'
};
const closeHintStyle = {
marginTop: '50px',
fontSize: '11px',
color: '#999',
cursor: 'pointer',
textAlign: 'center'
};
const languageSwitchStyle = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: '25px',
marginTop: '10px',
gap: '0px',
fontSize: '11px',
fontFamily: "'IBM Plex Mono', monospace"
};
const getLangStyle = (isActive) => ({
padding: '3px 8px',
cursor: 'pointer',
transition: 'all 0.2s',
background: isActive ? '#000' : '#fff',
color: isActive ? '#fff' : '#000',
border: 'none'
});
const content = {
en: {
question: "What happens if AI models don't compete with each other, but instead trade like a ",
questionHighlight: "well-coordinated, high-performance team",
questionEnd: "?",
intro: "Not arena, but TEAM. We Hope that AI is no longer entering the financial markets as isolated models—it is stepping in as ",
introHighlight1: "teams",
introContinue: ", collaborating in one of the most challenging and noise-filled ",
introHighlight2: "real-time environments",
introContinue2: ".",
point1Highlight: "✦ Complementary skills",
point1: " - across multiple agents—data analysis, strategy generation, risk management—working together like a real trading desk, exchanging information through notifications and meetings.",
point2Highlight: "✦ An agent system that continually evolves",
point2: " — with memory modules that retain experience, learn from market feedback, reflect, and develop their own methodology over time.",
point3Highlight: "✦ AI teams interacting with live markets",
point3: " — learning from real-time data and making immediate decisions, not just theoretical simulations."
},
zh: {
intro: "如果不是让模型彼此竞争,而是像一支高效协作的团队一样进行实时交易,会发生什么?",
question: "这里不是竞技场而是团队。我们希望Agents不再单打独斗而是「组团」进入实时金融市场——这一十分困难且充满噪声的环境。",
trying: "我们正在探索多智能体协作在实时金融交易中的可能性。",
title1: "✦ 多智能体的技能互补",
point1: "不同模型、不同角色的智能体像真实的金融团队一样协作,各自承担数据分析、策略生成、风险控制等职责。",
point1Sub: "通过通知和会议机制进行信息交换,实现高效协作。",
title2: "✦ 能够持续进化的智能体系统",
point2: "依托「记忆」模块每个智能体都能跨回合保留经验不断学习、反思与调整。我们希望能看到在长期实时交易中Agent形成自己的独特方法论而不是一次性偶然的推理。",
point2Sub: "ReMe 记忆框架帮助 Agents 持续改进。",
title3: "✦ 实时参与市场的 AI Agents",
point3: "Agents从实时行情中学习并给予即时决策不是纸上谈兵而是面对市场的真实波动。"
}
};
return (
<>
<style>{`
@keyframes expandDown {
from {
transform: scaleY(0);
opacity: 0;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
@keyframes collapseUp {
from {
transform: scaleY(1);
opacity: 1;
}
to {
transform: scaleY(0);
opacity: 0;
}
}
@keyframes fadeInContent {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOutContent {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-20px);
}
}
`}</style>
<div style={overlayStyle} onClick={handleClose}>
{/* Header */}
<div className="header" style={{
animation: isClosing
? 'fadeOutContent 0.4s ease forwards'
: 'fadeInContent 0.8s ease 0.3s backwards'
}} onClick={(e) => e.stopPropagation()}>
<Header
onEvoTradersClick={handleClose}
evoTradersLinkStyle="close"
/>
</div>
{/* Content */}
<div style={contentStyle} onClick={(e) => e.stopPropagation()}>
<div style={languageSwitchStyle}>
<span
style={getLangStyle(true)}
>
中文
</span>
</div>
// Chinese Content
<>
<div style={{ marginBottom: '30px' }}>
{content.zh.intro}
</div>
<div style={{ marginBottom: '40px', fontSize: '15px', fontWeight: 600 }}>
{content.zh.question}
</div>
<div style={{ marginBottom: '30px', fontSize: '14px', opacity: 0.8 }}>
{content.zh.trying}
</div>
<div style={{ marginBottom: '30px' }}>
<div style={{ ...highlight, marginBottom: '10px' }}>
{content.zh.title1}
</div>
<div style={{ marginBottom: '10px' }}>
{content.zh.point1}
</div>
<div style={{ fontSize: '13px', opacity: 0.7 }}>
{content.zh.point1Sub}
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<div style={{ ...highlight, marginBottom: '10px' }}>
{content.zh.title2}
</div>
<div style={{ marginBottom: '10px' }}>
{content.zh.point2}
</div>
<div style={{ fontSize: '13px', opacity: 0.7 }}>
{content.zh.point2Sub}
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<div style={{ ...highlight, marginBottom: '10px' }}>
{content.zh.title3}
</div>
<div>
{content.zh.point3}
</div>
</div>
<div style={{ marginBottom: '10px', opacity: 0.7 }}>
我们已经在 GitHub 上开源
</div>
<div style={{ marginBottom: '25px', opacity: 0.7 }}>
EvoTraders 基于{' '}
<a
href="https://github.com/agentscope-ai"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
AgentScope
</a>
{' '}搭建并使用其中的{' '}
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
ReMe
</a>
{' '}作为记忆管理核心
</div>
<div style={{ marginBottom: '10px', fontSize: '14px' }}>
你可以在此找到完整项目与示例
</div>
</>
<div style={{ marginTop: '40px' }}>
<a
href="https://github.com/agentscope-ai/agentscope-samples"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
github.com/agentscope-ai/agentscope-samples
</a>
</div>
<div style={closeHintStyle} onClick={handleClose}>
点击此处关闭
</div>
</div>
</div>
</>
);
}

View File

@@ -149,7 +149,7 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
// Get current selection display info // Get current selection display info
const getCurrentSelectionInfo = () => { const getCurrentSelectionInfo = () => {
if (selectedAgent === 'all') { if (selectedAgent === 'all') {
return { label: 'All Agents', modelInfo: null }; return { label: '全部角色', modelInfo: null };
} }
const agentInfo = getAgentInfoByName(selectedAgent); const agentInfo = getAgentInfoByName(selectedAgent);
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null; const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
@@ -191,7 +191,7 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
setDropdownOpen(false); setDropdownOpen(false);
}} }}
> >
<span>全部 Agents</span> <span>全部角色</span>
</div> </div>
{uniqueAgents.map(agent => { {uniqueAgents.map(agent => {
const agentInfo = getAgentInfoByName(agent); const agentInfo = getAgentInfoByName(agent);
@@ -419,17 +419,14 @@ function MemoryItem({ memory, itemId, isHighlighted }) {
onMouseEnter={() => setShowTooltip(true)} onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)} onMouseLeave={() => setShowTooltip(false)}
> >
<a <span
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }} style={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }}
> >
<img <img
src={ASSETS.remeLogo} src={ASSETS.remeLogo}
alt="ReMe" alt="Memory"
style={{ style={{
cursor: 'pointer', cursor: 'default',
height: '12px', height: '12px',
width: 'auto', width: 'auto',
objectFit: 'contain', objectFit: 'contain',
@@ -449,9 +446,9 @@ function MemoryItem({ memory, itemId, isHighlighted }) {
lineHeight: 1, lineHeight: 1,
pointerEvents: 'none' pointerEvents: 'none'
}}> }}>
MEMORY
</span>
</span> </span>
</a>
</div> </div>
<span style={{ <span style={{
background: 'linear-gradient(90deg, #00C2FF 0%, #5C4CE0 100%)', background: 'linear-gradient(90deg, #00C2FF 0%, #5C4CE0 100%)',
@@ -497,10 +494,10 @@ function MemoryItem({ memory, itemId, isHighlighted }) {
color: 'transparent', color: 'transparent',
display: 'inline-block' display: 'inline-block'
}}> }}>
Memory powered by AgentScope-ReMe Runtime Memory Layer
</div> </div>
<div style={{ color: '#475569', opacity: 0.9 }}> <div style={{ color: '#475569', opacity: 0.9 }}>
Not only retrieves historical memories but also generates suggestions and hints for the current task based on latest context. Retrieves relevant historical context and produces guidance for the current task based on the latest conversation state.
</div> </div>
</div> </div>

View File

@@ -1,253 +1,29 @@
import React, { useState } from 'react'; import React from 'react';
/** /**
* Header Component * Header Component
* Reusable header brand with EvoTraders logo, GitHub link, and Contact Us section * Reusable header brand for EvoTraders.
*
* @param {Function} onEvoTradersClick - Optional callback when EvoTraders is clicked
* @param {string} evoTradersLinkStyle - Optional style variant: 'default' | 'close'
*/ */
export default function Header({ export default function Header() {
onEvoTradersClick = null,
evoTradersLinkStyle = 'default' // 'default' shows ↗, 'close' shows ↙
}) {
const [activeContactCard, setActiveContactCard] = useState({ yue: false, jiaji: false });
const [clickedContactCard, setClickedContactCard] = useState(null);
const handleEvoTradersClick = () => {
if (onEvoTradersClick) {
onEvoTradersClick();
}
};
return ( return (
<div className="header-title" style={{ flex: '0 1 auto', minWidth: 0 }}> <div className="header-title" style={{ flex: '0 1 auto', minWidth: 0 }}>
<span <span
className="header-link" className="header-link"
onClick={handleEvoTradersClick} style={{
style={{ cursor: 'pointer', padding: '4px 8px', borderRadius: '3px', display: 'inline-flex', alignItems: 'center', gap: '8px' }} padding: '4px 8px',
borderRadius: '3px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px'
}}
> >
<img <img
src="/trading_logo.png" src="/trading_logo.png"
alt="EvoTraders Logo" alt="EvoTraders Logo"
style={{ height: '24px', width: 'auto' }} style={{ height: '24px', width: 'auto' }}
/> />
EvoTraders {evoTradersLinkStyle === 'close' ? ( EvoTraders
<span className="link-arrow"></span>
) : (
<span className="link-arrow"></span>
)}
</span> </span>
<span style={{
width: '2px',
height: '16px',
background: '#666',
margin: '0 16px',
display: 'inline-block',
verticalAlign: 'middle'
}} />
<span style={{
padding: '1px 5px',
fontSize: '9px',
fontWeight: 700,
color: '#00C853',
background: 'rgba(0, 200, 83, 0.1)',
border: '1px solid #00C853',
borderRadius: '3px',
letterSpacing: '0.5px',
marginRight: '0px'
}}>
开源
</span>
<a
href="https://github.com/agentscope-ai/agentscope-samples"
target="_blank"
rel="noopener noreferrer"
className="header-link"
style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: '6px' }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
style={{ display: 'inline-block' }}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>agentscope-samples</span>
<span className="link-arrow"></span>
</a>
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
className="header-link"
style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: '6px', marginLeft: '0px' }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
style={{ display: 'inline-block' }}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>agentscope-ReMe</span>
<span className="link-arrow"></span>
</a>
<span style={{
width: '2px',
height: '16px',
background: '#666',
margin: '0 16px',
display: 'inline-block',
verticalAlign: 'middle'
}} />
<div
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
onClick={() => {
const bothActive = activeContactCard.yue && activeContactCard.jiaji;
if (!bothActive) {
setActiveContactCard({ yue: true, jiaji: true });
setClickedContactCard('both');
} else {
setActiveContactCard({ yue: false, jiaji: false });
setClickedContactCard(null);
}
}}
>
<span className="header-link">
联系我们
</span>
{/* Two contact buttons */}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<div
onClick={(e) => {
e.stopPropagation();
if (activeContactCard.yue) {
setActiveContactCard(prev => ({ ...prev, yue: false }));
if (clickedContactCard === 'yue' || clickedContactCard === 'both') {
setClickedContactCard(null);
}
} else {
setActiveContactCard(prev => ({ ...prev, yue: true }));
setClickedContactCard('yue');
}
}}
onMouseEnter={() => {
if (!clickedContactCard || clickedContactCard === 'yue' || clickedContactCard === 'both') {
setActiveContactCard(prev => ({ ...prev, yue: true }));
}
}}
onMouseLeave={() => {
if (clickedContactCard !== 'yue' && clickedContactCard !== 'both') {
setActiveContactCard(prev => ({ ...prev, yue: false }));
}
}}
style={{
padding: '4px 8px',
background: activeContactCard.yue ? '#615CED' : '#f5f5f5',
color: activeContactCard.yue ? '#fff' : '#333',
border: '1px solid',
borderColor: activeContactCard.yue ? '#615CED' : '#e0e0e0',
borderRadius: '3px',
fontSize: '10px',
fontWeight: 700,
fontFamily: "'IBM Plex Mono', monospace",
cursor: 'pointer',
transition: 'all 0.2s',
letterSpacing: '0.5px',
whiteSpace: 'nowrap',
overflow: 'hidden',
maxWidth: activeContactCard.yue ? '80px' : '32px',
minWidth: activeContactCard.yue ? '80px' : '32px'
}}
>
{activeContactCard.yue ? (
<a
href="https://1mycell.github.io/"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit', textDecoration: 'none' }}
onClick={(e) => e.stopPropagation()}
>
Yue Wu
</a>
) : 'YW'}
</div>
<div
onClick={(e) => {
e.stopPropagation();
if (activeContactCard.jiaji) {
setActiveContactCard(prev => ({ ...prev, jiaji: false }));
if (clickedContactCard === 'jiaji' || clickedContactCard === 'both') {
setClickedContactCard(null);
}
} else {
setActiveContactCard(prev => ({ ...prev, jiaji: true }));
setClickedContactCard('jiaji');
}
}}
onMouseEnter={() => {
if (!clickedContactCard || clickedContactCard === 'jiaji' || clickedContactCard === 'both') {
setActiveContactCard(prev => ({ ...prev, jiaji: true }));
}
}}
onMouseLeave={() => {
if (clickedContactCard !== 'jiaji' && clickedContactCard !== 'both') {
setActiveContactCard(prev => ({ ...prev, jiaji: false }));
}
}}
style={{
padding: '4px 8px',
background: activeContactCard.jiaji ? '#615CED' : '#f5f5f5',
color: activeContactCard.jiaji ? '#fff' : '#333',
border: '1px solid',
borderColor: activeContactCard.jiaji ? '#615CED' : '#e0e0e0',
borderRadius: '3px',
fontSize: '10px',
fontWeight: 700,
fontFamily: "'IBM Plex Mono', monospace",
cursor: 'pointer',
transition: 'all 0.2s',
letterSpacing: '0.5px',
whiteSpace: 'nowrap',
overflow: 'hidden',
maxWidth: activeContactCard.jiaji ? '100px' : '32px',
minWidth: activeContactCard.jiaji ? '100px' : '32px'
}}
>
{activeContactCard.jiaji ? (
<a
href="https://dengjiaji.github.io/self/"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit', textDecoration: 'none' }}
onClick={(e) => e.stopPropagation()}
>
Jiaji Deng
</a>
) : 'JD'}
</div>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -554,7 +554,7 @@ export default function NetValueChart({ equity, baseline, baseline_vw, momentum,
fontFamily: '"Courier New", monospace', fontFamily: '"Courier New", monospace',
fontSize: '12px' fontSize: '12px'
}}> }}>
NO DATA AVAILABLE 暂无图表数据
</div> </div>
); );
} }
@@ -828,4 +828,3 @@ export default function NetValueChart({ equity, baseline, baseline_vw, momentum,
</ResponsiveContainer> </ResponsiveContainer>
); );
} }

View File

@@ -1,360 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { LLM_MODEL_LOGOS } from '../config/constants';
export default function RulesView() {
const [language] = useState('zh');
const [scale, setScale] = useState(1);
const containerRef = useRef(null);
const contentRef = useRef(null);
// Auto-scale content to fit container without scrolling
useEffect(() => {
const handleResize = () => {
if (containerRef.current && contentRef.current) {
const containerHeight = containerRef.current.clientHeight;
const contentHeight = contentRef.current.scrollHeight;
if (contentHeight > containerHeight) {
const newScale = containerHeight / contentHeight;
setScale(Math.max(newScale * 0.95, 0.5)); // Min scale 0.5, with 95% of available space
} else {
setScale(1);
}
}
};
// Initial resize
handleResize();
// Listen to window resize
window.addEventListener('resize', handleResize);
// Observe content changes
const observer = new ResizeObserver(handleResize);
if (contentRef.current) {
observer.observe(contentRef.current);
}
return () => {
window.removeEventListener('resize', handleResize);
observer.disconnect();
};
}, [language]);
const containerStyle = {
width: '100%',
height: '100%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#FFFFFF',
padding: '10px'
};
const contentWrapperStyle = {
transform: `scale(${scale})`,
transformOrigin: 'center center',
transition: 'transform 0.3s ease',
width: '100%',
maxWidth: '900px'
};
const innerContentStyle = {
color: '#000000',
fontFamily: "'IBM Plex Mono', monospace",
fontSize: '13px',
lineHeight: '1.6',
letterSpacing: '0.01em',
padding: '0 10px'
};
const highlight = {
color: '#000000',
fontWeight: 700
};
const sectionTitleStyle = {
color: '#615CED',
fontSize: '16px',
fontWeight: 700,
marginBottom: '8px',
marginTop: '12px',
marginLeft: '-10px',
marginRight: '-10px',
width: 'calc(100% + 20px)',
padding: '8px 10px',
backgroundColor: '#FFFFFF',
letterSpacing: '0.5px',
boxSizing: 'border-box'
};
const subsectionStyle = {
marginBottom: '8px',
paddingLeft: '10px',
borderLeft: '2px solid #CCCCCC'
};
const linkStyle = {
color: '#615CED',
textDecoration: 'none',
borderBottom: '1px solid #615CED',
transition: 'all 0.2s'
};
const languageSwitchStyle = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: '12px',
gap: '0px',
fontSize: '11px',
fontFamily: "'IBM Plex Mono', monospace"
};
const getLangStyle = (isActive) => ({
padding: '4px 10px',
cursor: 'pointer',
transition: 'all 0.2s',
background: isActive ? '#000000' : 'transparent',
color: isActive ? '#FFFFFF' : '#666666',
border: 'none',
borderRadius: '2px'
});
const llmLogos = [
{ name: 'Alibaba', file: 'Alibaba.jpeg', label: 'Qwen', url: LLM_MODEL_LOGOS['Alibaba'] },
{ name: 'DeepSeek', file: 'DeepSeek.png', label: 'DeepSeek', url: LLM_MODEL_LOGOS['DeepSeek'] },
{ name: 'Moonshot', file: 'Moonshot.jpeg', label: 'Moonshot', url: LLM_MODEL_LOGOS['Moonshot'] },
{ name: 'Zhipu AI', file: 'Zhipu AI.png', label: 'Zhipu AI', url: LLM_MODEL_LOGOS['Zhipu AI'] }
];
const content = {
en: {
section1Title: "Agent Setup",
pmRole: "Portfolio Manager",
pmDesc: "Makes final trading decisions and orchestrates team collaboration",
rmRole: "Risk Manager",
rmDesc: "Monitors portfolio risk and enforces risk limits",
analystsRole: "Analysts",
analystsDesc: "Conduct specialized research with different tools and AI models:",
analysts: [
{ name: "Valuation Analyst", model: "Moonshot", modelKey: "Moonshot" },
{ name: "Sentiment Analyst", model: "Qwen", modelKey: "Alibaba" },
{ name: "Fundamentals Analyst", model: "DeepSeek", modelKey: "DeepSeek" },
{ name: "Technical Analyst", model: "Zhipu AI", modelKey: "Zhipu AI" }
],
section2Title: "Agent Decision Mechanism",
tradingProcess: "Daily Trading Process",
tradingDesc: "Agents trade on a daily frequency while continuously tracking portfolio performance. Before each day's final trading decision, agents go through three key phases:",
analysisPhase: "• Analysis Phase",
analysisDesc: "All agents independently analyze information and form judgments based on their specialized tools.",
communicationPhase: "• Communication Phase",
commIntro: "Multiple communication channels enable effective collaboration: 1v1 Private Chat / 1vN Notification / NvN Conference",
decisionPhase: "• Decision Phase",
decisionDesc: "Portfolio Manager aggregates all information and makes the final team trading decision. The original trading signals from analysts are only used for individual-level ranking.",
reflectionTitle: "Learning & Evolution",
reflectionDesc: "Agents reflect on daily investment performance, summarize insights, and store them in ",
remeLink: "ReMe",
reflectionDesc2: " memory framework for continuous improvement.",
section3Title: "Performance Evaluation",
chartTitle: "• Performance Chart",
chartDesc: "Track portfolio equity curve vs. benchmarks (equal-weight, value-weighted, momentum). Use this to assess overall strategy effectiveness.",
rankingTitle: "• Analyst Rankings",
rankingDesc: "Click avatars in Trading Room to view analyst performance (Win Rate, Bull/Bear Win Rate). Use this to understand which analysts provide the most valuable insights.",
statsTitle: "• Statistics",
statsDesc: "Detailed holdings and trade history. Use this for in-depth analysis of position management and execution quality.",
callToAction: "Fork on ",
repoLink: "GitHub",
callToAction2: " to customize!"
},
zh: {
section1Title: "Agent 设定",
pmRole: "投资经理",
pmDesc: "负责最终交易决策和团队协作",
rmRole: "风控经理",
rmDesc: "监控组合风险并执行风险限制",
analystsRole: "分析师",
analystsDesc: "使用不同工具和 AI 模型进行专业研究:",
analysts: [
{ name: "估值分析师", model: "Moonshot", modelKey: "Moonshot" },
{ name: "情绪分析师", model: "Qwen", modelKey: "Alibaba" },
{ name: "基本面分析师", model: "DeepSeek", modelKey: "DeepSeek" },
{ name: "技术分析师", model: "Zhipu AI", modelKey: "Zhipu AI" }
],
section2Title: "Agent 决策机制",
tradingProcess: "交易流程",
tradingDesc: "智能体以日频进行交易并持续跟踪组合净值。每天最终交易决策前,会经历三个关键阶段:",
analysisPhase: "• 分析阶段",
analysisDesc: "所有智能体根据各自的工具和信息独立分析并形成判断。",
communicationPhase: "• 沟通阶段",
commIntro: "提供了多种沟通渠道1v1 私聊 / 1vN 通知 / NvN 会议",
decisionPhase: "• 决策阶段",
decisionDesc: "由投资经理汇总所有信息,并给出最终的团队交易决策。分析师给出的原始交易信号仅用于个人维度排名。",
reflectionTitle: "学习与进化",
reflectionDesc: "智能体根据当日实际收益反思决策、总结经验,并存入 ",
remeLink: "ReMe",
reflectionDesc2: " 记忆框架以持续改进。",
section3Title: "收益评估",
chartTitle: "• 业绩图表",
chartDesc: "追踪组合收益曲线 vs. 基准策略(等权、市值加权、动量)。用于评估整体策略有效性。",
rankingTitle: "• 分析师排名",
rankingDesc: "在交易室点击头像查看分析师表现(胜率、牛/熊市胜率),用来了解哪些分析师提供了最有价值的洞察。",
statsTitle: "• 统计数据",
statsDesc: "详细的持仓和交易历史。用于深入分析仓位管理和执行质量。",
callToAction: "可在 ",
repoLink: "GitHub",
callToAction2: " 上 Fork 并自行定制。"
}
};
return (
<div ref={containerRef} style={containerStyle}>
<div ref={contentRef} style={contentWrapperStyle}>
<div style={innerContentStyle}>
<div style={languageSwitchStyle}>
<span
style={getLangStyle(true)}
>
中文
</span>
</div>
// Chinese Content
<>
{/* 第一部分Agent 设定 */}
<div style={sectionTitleStyle}>{content.zh.section1Title}</div>
{/* 角色 */}
<div style={{ marginBottom: '8px', fontSize: '12px' }}>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.pmRole}:</span> {content.zh.pmDesc}
</div>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.rmRole}:</span> {content.zh.rmDesc}
</div>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.analystsRole}:</span> {content.zh.analystsDesc}
</div>
</div>
{/* Analysts 与 AI 模型 */}
<div style={{ marginLeft: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 14px', fontSize: '11px' }}>
{content.zh.analysts.map(analyst => {
const logo = llmLogos.find(l => l.name === analyst.modelKey);
return (
<div key={analyst.name} style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{logo && (
<img
src={logo.url}
alt={logo.label}
style={{
height: '16px',
width: 'auto',
objectFit: 'contain'
}}
/>
)}
<span style={{ fontWeight: 600 }}>{analyst.name}</span>
<span style={{ color: '#666' }}>- {analyst.model}</span>
</div>
);
})}
</div>
<div style={{ marginBottom: '10px', fontSize: '11px', fontStyle: 'italic', opacity: 0.8 }}>
{content.zh.callToAction}
<a
href="https://github.com/agentscope-ai/agentscope-samples"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
{content.zh.repoLink}
</a>
{content.zh.callToAction2}
</div>
{/* 第二部分Agent 决策机制 */}
<div style={sectionTitleStyle}>{content.zh.section2Title}</div>
<div style={{ marginBottom: '6px' }}>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{content.zh.tradingProcess}</div>
<div style={{ marginBottom: '6px', fontSize: '12px' }}>{content.zh.tradingDesc}</div>
<div style={subsectionStyle}>
<div style={{ marginBottom: '4px', fontSize: '12px' }}>
<span style={highlight}>{content.zh.analysisPhase.replace('• ', '')}:</span> {content.zh.analysisDesc}
</div>
<div style={{ marginBottom: '4px', fontSize: '12px' }}>
<span style={highlight}>{content.zh.communicationPhase.replace('• ', '')}:</span> {content.zh.commIntro}
</div>
<div style={{ fontSize: '12px' }}>
<span style={highlight}>{content.zh.decisionPhase.replace('• ', '')}:</span> {content.zh.decisionDesc}
</div>
</div>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{content.zh.reflectionTitle}</div>
<div style={{ fontSize: '12px' }}>
{content.zh.reflectionDesc}
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
{content.zh.remeLink}
</a>
{content.zh.reflectionDesc2}
</div>
</div>
{/* 第三部分:收益评估 */}
<div style={sectionTitleStyle}>{content.zh.section3Title}</div>
<div style={subsectionStyle}>
<div style={{ marginBottom: '3px', fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.chartTitle.replace('• ', '')}:</span> {content.zh.chartDesc}
</div>
<div style={{ marginBottom: '3px', fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.rankingTitle.replace('• ', '')}:</span> {content.zh.rankingDesc}
</div>
<div style={{ fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.statsTitle.replace('• ', '')}:</span> {content.zh.statsDesc}
</div>
</div>
</>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
import React from 'react';
export default function WatchlistPanel({
isOpen,
isConnected,
isSaving,
draftSymbols,
inputValue,
feedback,
suggestions,
onToggle,
onClose,
onInputChange,
onInputKeyDown,
onAdd,
onRemove,
onRestoreCurrent,
onRestoreDefault,
onSuggestionClick,
onSave
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative' }}>
<button
onClick={onToggle}
style={{
padding: '6px 10px',
borderRadius: 4,
border: '1px solid #333333',
background: isOpen ? '#1E1E1E' : '#111111',
color: '#FFFFFF',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.6px',
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
>
WATCHLIST
</button>
{isOpen && (
<div style={{
position: 'absolute',
top: 'calc(100% + 10px)',
right: 0,
width: 360,
maxWidth: 'min(360px, 92vw)',
padding: '14px',
borderRadius: 8,
border: '1px solid #D9D9D9',
background: '#FFFFFF',
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.14)',
zIndex: 40,
display: 'flex',
flexDirection: 'column',
gap: 12
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div>
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
自选股管理
</div>
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
保存后会立即更新当前 run watchlist
</div>
</div>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
color: '#666666',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1
}}
>
×
</button>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
minHeight: 36,
padding: '2px 0'
}}>
{draftSymbols.map((symbol) => (
<button
key={symbol}
onClick={() => onRemove(symbol)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 999,
border: '1px solid #D0D7DE',
background: '#F7F9FB',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
<span>{symbol}</span>
<span style={{ color: '#777777' }}>×</span>
</button>
))}
{draftSymbols.length === 0 && (
<div style={{ fontSize: '11px', color: '#888888', padding: '8px 2px' }}>
还没有股票输入代码后回车添加
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onInputKeyDown}
placeholder="输入股票代码,回车添加"
style={{
flex: 1,
padding: '9px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
<button
onClick={onAdd}
style={{
padding: '9px 12px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#F7F9FB',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
添加
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{suggestions.map((symbol) => {
const active = draftSymbols.includes(symbol);
return (
<button
key={symbol}
onClick={() => onSuggestionClick(symbol)}
disabled={active}
style={{
padding: '5px 8px',
borderRadius: 999,
border: '1px solid',
borderColor: active ? '#B6E3C5' : '#D0D7DE',
background: active ? '#ECFDF3' : '#FFFFFF',
color: active ? '#157347' : '#4A5568',
fontSize: '10px',
fontWeight: 700,
cursor: active ? 'default' : 'pointer'
}}
>
{symbol}
</button>
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={onRestoreCurrent}
style={{
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
恢复当前
</button>
<button
onClick={onRestoreDefault}
style={{
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
恢复默认
</button>
</div>
<button
onClick={onSave}
disabled={!isConnected || isSaving}
style={{
padding: '9px 14px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.4px',
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
}}
>
{isSaving ? '保存中' : '保存'}
</button>
</div>
{feedback && (
<span style={{
color: feedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: '11px',
fontFamily: '"Courier New", monospace'
}}>
{feedback.text}
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,88 @@ import { AGENTS } from "../config/constants";
const MAX_FEED_ITEMS = 200; const MAX_FEED_ITEMS = 200;
const normalizeSystemContent = (content) => {
if (typeof content !== "string") {
return content;
}
const trimmed = content.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed === "Runtime assets reloaded." || trimmed === "运行时配置已热更新") {
return "配置已刷新";
}
if (trimmed.startsWith("Watchlist updated:")) {
const symbols = trimmed.replace("Watchlist updated:", "").trim();
return symbols ? `自选已更新: ${symbols}` : "自选已更新";
}
if (trimmed === "已连接实时数据服务") {
return "已连接";
}
if (trimmed === "正在尝试连接数据服务...") {
return "连接中...";
}
if (trimmed.startsWith("day_start:")) {
const value = trimmed.replace("day_start:", "").trim();
return value ? `交易日开始:${value}` : "交易日开始";
}
if (trimmed.startsWith("day_complete:")) {
const value = trimmed.replace("day_complete:", "").trim();
return value ? `交易日结束:${value}` : "交易日结束";
}
if (trimmed.startsWith("day_error:")) {
const value = trimmed.replace("day_error:", "").trim();
return value ? `交易日异常:${value}` : "交易日异常";
}
return trimmed;
};
const normalizeConferenceTitle = (title) => {
if (typeof title !== "string") {
return "投资讨论";
}
const trimmed = title.trim();
if (!trimmed) {
return "投资讨论";
}
if (trimmed.startsWith("Investment Discussion -")) {
const date = trimmed.replace("Investment Discussion -", "").trim();
return date ? `投资讨论 · ${date}` : "投资讨论";
}
if (trimmed === "Team Conference") {
return "投资讨论";
}
return trimmed;
};
const normalizeAgentLabel = (agentName, agentId) => {
if (typeof agentName === "string") {
const trimmed = agentName.trim();
if (trimmed.toLowerCase() === "conference summary") {
return "会议总结";
}
}
if (typeof agentId === "string" && agentId.trim().toLowerCase() === "conference summary") {
return "会议总结";
}
return agentName;
};
/** /**
* Generate a unique ID for feed items * Generate a unique ID for feed items
*/ */
@@ -26,7 +108,7 @@ const eventToMessage = (evt) => {
id: generateId("msg"), id: generateId("msg"),
timestamp, timestamp,
agentId: evt.agentId, agentId: evt.agentId,
agent: agent?.name || evt.agentName || evt.agentId || "Agent", agent: normalizeAgentLabel(agent?.name || evt.agentName || evt.agentId || "Agent", evt.agentId),
role: agent?.role || evt.role || "Agent", role: agent?.role || evt.role || "Agent",
content: evt.content content: evt.content
}; };
@@ -50,7 +132,7 @@ const eventToMessage = (evt) => {
timestamp, timestamp,
agent: "System", agent: "System",
role: "System", role: "System",
content: evt.content || `${evt.type}: ${evt.date || ""}` content: normalizeSystemContent(evt.content || `${evt.type}: ${evt.date || ""}`)
}; };
default: default:
@@ -129,7 +211,7 @@ export function useFeedProcessor() {
// Start a new conference // Start a new conference
currentConference = { currentConference = {
id: evt.conferenceId || generateId("conf"), id: evt.conferenceId || generateId("conf"),
title: evt.title || "Team Conference", title: normalizeConferenceTitle(evt.title || "Team Conference"),
startTime: evt.timestamp || evt.ts || Date.now(), startTime: evt.timestamp || evt.ts || Date.now(),
endTime: null, endTime: null,
isLive: false, isLive: false,
@@ -209,7 +291,7 @@ export function useFeedProcessor() {
if (evt.type === "conference_start") { if (evt.type === "conference_start") {
const conference = { const conference = {
id: evt.conferenceId || generateId("conf"), id: evt.conferenceId || generateId("conf"),
title: evt.title || "Team Conference", title: normalizeConferenceTitle(evt.title || "Team Conference"),
startTime: evt.timestamp || evt.ts || Date.now(), startTime: evt.timestamp || evt.ts || Date.now(),
endTime: null, endTime: null,
isLive: true, isLive: true,
@@ -312,7 +394,7 @@ export function useFeedProcessor() {
timestamp: Date.now(), timestamp: Date.now(),
agent: "System", agent: "System",
role: "System", role: "System",
content content: normalizeSystemContent(content)
}; };
const activeConf = activeConferenceRef.current; const activeConf = activeConferenceRef.current;

View File

@@ -1030,8 +1030,9 @@ export default function GlobalStyles() {
/* Three-view slider (Room / Chart / Statistics) */ /* Three-view slider (Room / Chart / Statistics) */
.view-slider-three { .view-slider-three {
position: absolute; position: absolute;
top: 40px;
width: 300%; width: 300%;
height: 100%; height: calc(100% - 40px);
display: flex; display: flex;
transition: transform 1.6s cubic-bezier(0.34, 1.56, 0.64, 1); transition: transform 1.6s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@@ -1052,7 +1053,7 @@ export default function GlobalStyles() {
transform: translateX(-66.666%); transform: translateX(-66.666%);
} }
/* Four-view slider (Rules / Room / Chart / Statistics) */ /* Four-view slider (Room / Explain / Chart / Statistics) */
.view-slider-four { .view-slider-four {
position: absolute; position: absolute;
top: 40px; top: 40px;
@@ -1066,11 +1067,11 @@ export default function GlobalStyles() {
transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
.view-slider-four.show-rules { .view-slider-four.show-room {
transform: translateX(0); transform: translateX(0);
} }
.view-slider-four.show-room { .view-slider-four.show-explain {
transform: translateX(-25%); transform: translateX(-25%);
} }
@@ -1873,4 +1874,3 @@ export default function GlobalStyles() {
`}</style> `}</style>
); );
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,134 @@
[
{
"agentId": "portfolio_manager",
"name": "投资经理",
"role": "投资经理",
"avatar": "pm",
"rank": null,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "risk_manager",
"name": "风控经理",
"role": "风控经理",
"avatar": "risk",
"rank": null,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "sentiment_analyst",
"name": "情绪分析师",
"role": "情绪分析师",
"avatar": "sentiment",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "technical_analyst",
"name": "技术分析师",
"role": "技术分析师",
"avatar": "technical",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "fundamentals_analyst",
"name": "基本面分析师",
"role": "基本面分析师",
"avatar": "fundamentals",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "valuation_analyst",
"name": "估值分析师",
"role": "估值分析师",
"avatar": "valuation",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
}
]

View File

@@ -0,0 +1,18 @@
{
"totalAssetValue": 100000.0,
"totalReturn": 0.0,
"cashPosition": 100000.0,
"tickerWeights": {},
"totalTrades": 0,
"winRate": 0.0,
"bullBear": {
"bull": {
"n": 0,
"win": 0
},
"bear": {
"n": 0,
"win": 0
}
}
}

View File

@@ -0,0 +1,13 @@
{
"totalAssetValue": 100000.0,
"totalReturn": 0.0,
"cashPosition": 100000.0,
"tickerWeights": {},
"totalTrades": 0,
"pnlPct": 0.0,
"balance": 100000.0,
"equity": [],
"baseline": [],
"baseline_vw": [],
"momentum": []
}

View File

@@ -0,0 +1 @@
[]

1
reference/PokieTicker Submodule

Submodule reference/PokieTicker added at 4fed7755e5

View File

@@ -0,0 +1,18 @@
---
tickers:
- NVDA
- AMD
initial_cash: 250000
margin_requirement: 0.5
enable_memory: false
max_comm_cycles: 1
agent_overrides:
risk_manager:
active_tool_groups:
- risk_ops
---
# Bootstrap
Prefer semiconductor names in this run.
Keep decisions terse and explicitly mention concentration risk.

View File

@@ -0,0 +1,5 @@
# Policy
Optional run-scoped constraints, limits, or strategy policy.
State a clear signal, confidence, and the conditions that would invalidate the thesis.

View File

@@ -0,0 +1,11 @@
# Role
Optional run-scoped role override.
作为基本面分析师,你专注于:
- 公司财务健康状况和盈利能力
- 商业模式可持续性和竞争优势
- 管理层质量和公司治理
- 行业地位和市场份额
- 长期投资价值评估
你倾向于选择能够深入了解公司内在价值的工具,更偏好基本面和估值类工具。

View File

@@ -0,0 +1,9 @@
# Style
Optional run-scoped communication or reasoning style.
- 公司财务健康状况和盈利能力
- 商业模式可持续性和竞争优势
- 管理层质量和公司治理
- 行业地位和市场份额
- 长期投资价值评估

View File

@@ -0,0 +1,5 @@
# Policy
Optional run-scoped constraints, limits, or strategy policy.
Respect cash, margin, and portfolio concentration constraints before recording decisions.

View File

@@ -0,0 +1,5 @@
# Role
Optional run-scoped role override.
Synthesize analyst and risk inputs into explicit portfolio decisions.

View File

@@ -0,0 +1,5 @@
# Style
Optional run-scoped communication or reasoning style.
Be concise, capital-aware, and explicit about sizing rationale.

View File

@@ -0,0 +1,5 @@
# Policy
Optional run-scoped constraints, limits, or strategy policy.
Use available risk tools before issuing the final risk memo.

View File

@@ -0,0 +1,5 @@
# Role
Optional run-scoped role override.
Quantify concentration, leverage, liquidity, and volatility risk before trade execution.

View File

@@ -0,0 +1,6 @@
# Style
Optional run-scoped communication or reasoning style.
Prioritize the highest-severity semiconductor risk first.
Always state one concrete position limit and one liquidation trigger.

View File

@@ -0,0 +1,5 @@
# Policy
Optional run-scoped constraints, limits, or strategy policy.
State a clear signal, confidence, and the conditions that would invalidate the thesis.

View File

@@ -0,0 +1,11 @@
# Role
Optional run-scoped role override.
作为情绪分析师,你专注于:
- 市场参与者情绪变化
- 新闻舆情和媒体影响
- 内部人交易行为
- 投资者恐慌和贪婪情绪
- 市场预期和心理因素
你倾向于选择能够反映市场情绪和投资者行为的工具,更偏好情绪和行为类工具。

View File

@@ -0,0 +1,9 @@
# Style
Optional run-scoped communication or reasoning style.
- 市场参与者情绪变化
- 新闻舆情和媒体影响
- 内部人交易行为
- 投资者恐慌和贪婪情绪
- 市场预期和心理因素

View File

@@ -0,0 +1,5 @@
# Policy
Optional run-scoped constraints, limits, or strategy policy.
State a clear signal, confidence, and the conditions that would invalidate the thesis.

View File

@@ -0,0 +1,11 @@
# Role
Optional run-scoped role override.
作为技术分析师,你专注于:
- 价格趋势和图表形态
- 技术指标和交易信号
- 市场情绪和资金流向
- 支撑/阻力位和关键价格点
- 中短期交易机会
你倾向于选择能够捕捉价格动态和市场趋势的工具,更偏好技术分析类工具。

View File

@@ -0,0 +1,9 @@
# Style
Optional run-scoped communication or reasoning style.
- 价格趋势和图表形态
- 技术指标和交易信号
- 市场情绪和资金流向
- 支撑/阻力位和关键价格点
- 中短期交易机会

View File

@@ -0,0 +1,5 @@
# Policy
Optional run-scoped constraints, limits, or strategy policy.
State a clear signal, confidence, and the conditions that would invalidate the thesis.

View File

@@ -0,0 +1,11 @@
# Role
Optional run-scoped role override.
作为估值分析师,你专注于:
- 公司内在价值计算
- 不同估值方法的比较
- 估值模型假设和敏感性分析
- 相对估值和绝对估值
- 投资安全边际评估
你倾向于选择能够准确计算公司价值的工具,更偏好估值模型和基本面工具。

View File

@@ -0,0 +1,9 @@
# Style
Optional run-scoped communication or reasoning style.
- 公司内在价值计算
- 不同估值方法的比较
- 估值模型假设和敏感性分析
- 相对估值和绝对估值
- 投资安全边际评估

View File

@@ -0,0 +1,8 @@
global_enabled_skills: []
global_disabled_skills: []
agent_enabled_skills:
risk_manager:
- technical_review
portfolio_manager:
- risk_review
agent_disabled_skills: {}

View File

@@ -0,0 +1,21 @@
---
name: fundamental_review
description: Review a company from a fundamentals-first perspective before issuing a trading signal.
---
# Fundamental Review
Use this skill when the task requires judging business quality, balance-sheet strength, profitability, or long-term earnings durability.
## Workflow
1. Check profitability, growth, financial health, and efficiency before forming a conclusion.
2. Separate durable business quality from short-term noise.
3. State what would invalidate the thesis.
4. End with a clear signal, confidence, and the main drivers behind that signal.
## Guardrails
- Do not rely on one metric in isolation.
- Call out missing data explicitly.
- Prefer conservative conclusions when financial quality is mixed.

View File

@@ -0,0 +1,21 @@
---
name: portfolio_decisioning
description: Synthesize analyst inputs and risk feedback into explicit portfolio decisions.
---
# Portfolio Decisioning
Use this skill when you are responsible for converting team analysis into final trades.
## Workflow
1. Read analyst conclusions and risk warnings before acting.
2. Evaluate the current portfolio, cash, and margin constraints.
3. Record one explicit decision per ticker using the decision tool.
4. Summarize the portfolio-level rationale after all decisions are recorded.
## Guardrails
- Position sizing must respect capital and margin limits.
- Prefer smaller size when analyst conviction and risk signals disagree.
- Do not leave a ticker undecided when the task expects a full slate of decisions.

View File

@@ -0,0 +1,21 @@
---
name: risk_review
description: Assess portfolio and market risks before final position sizing and execution.
---
# Risk Review
Use this skill when you must identify concentration, volatility, leverage, and scenario risks.
## Workflow
1. Review the proposed exposure by ticker and theme.
2. Identify concentration, volatility, liquidity, and leverage concerns.
3. Rank warnings by severity.
4. Translate risk findings into concrete limits or cautions for the portfolio manager.
## Guardrails
- Focus on actionable risk controls.
- Quantify limits when the available data supports it.
- Distinguish fatal blockers from manageable risks.

View File

@@ -0,0 +1,21 @@
---
name: sentiment_review
description: Analyze news flow, market psychology, and insider behavior for catalyst-driven signals.
---
# Sentiment Review
Use this skill when the task depends on recent catalysts, news tone, or behavioral market signals.
## Workflow
1. Review recent news and identify the dominant narrative.
2. Check insider activity for confirming or conflicting signals.
3. Separate durable sentiment shifts from transient noise.
4. Explain how sentiment changes the near-term trade outlook.
## Guardrails
- Do not confuse attention with conviction.
- Highlight when sentiment is strong but unsupported by fundamentals.
- Be explicit about catalyst timing risk.

View File

@@ -0,0 +1,21 @@
---
name: technical_review
description: Evaluate price action, momentum, and volatility to judge timing and market regime.
---
# Technical Review
Use this skill when the task is sensitive to entry timing, trend quality, or short-term market structure.
## Workflow
1. Assess trend direction and strength.
2. Check momentum and mean-reversion conditions.
3. Review volatility before making aggressive recommendations.
4. Convert the setup into a trading view with explicit risk awareness.
## Guardrails
- Distinguish trend continuation from overshoot.
- Avoid strong conviction when signals conflict.
- Treat volatility as a sizing input, not only a directional input.

View File

@@ -0,0 +1,21 @@
---
name: valuation_review
description: Estimate fair value and margin of safety using multiple valuation lenses.
---
# Valuation Review
Use this skill when the task requires determining whether a stock is cheap, expensive, or fairly priced.
## Workflow
1. Use more than one valuation method when possible.
2. Compare intrinsic value estimates with current market pricing.
3. Explain the key assumptions behind the valuation view.
4. State the margin of safety and what could compress or expand it.
## Guardrails
- Treat valuation as a range, not a single precise number.
- Call out assumption sensitivity.
- Avoid high-confidence calls when inputs are sparse or unstable.

View File

@@ -0,0 +1,206 @@
{
"status": "running",
"current_date": "2026-03-16",
"portfolio": {
"total_value": 250000.0,
"cash": 250000.0,
"pnl_percent": 0.0,
"equity": [],
"baseline": [],
"baseline_vw": [],
"momentum": [],
"strategies": [],
"equity_return": [],
"baseline_return": [],
"baseline_vw_return": [],
"momentum_return": []
},
"holdings": [],
"trades": [],
"stats": {
"totalAssetValue": 250000.0,
"totalReturn": 0.0,
"cashPosition": 250000.0,
"tickerWeights": {},
"totalTrades": 0,
"winRate": 0.0,
"bullBear": {
"bull": {
"n": 0,
"win": 0
},
"bear": {
"n": 0,
"win": 0
}
}
},
"leaderboard": [
{
"agentId": "portfolio_manager",
"name": "投资经理",
"role": "投资经理",
"avatar": "pm",
"rank": null,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "risk_manager",
"name": "风控经理",
"role": "风控经理",
"avatar": "risk",
"rank": null,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "sentiment_analyst",
"name": "情绪分析师",
"role": "情绪分析师",
"avatar": "sentiment",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "technical_analyst",
"name": "技术分析师",
"role": "技术分析师",
"avatar": "technical",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "fundamentals_analyst",
"name": "基本面分析师",
"role": "基本面分析师",
"avatar": "fundamentals",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "valuation_analyst",
"name": "估值分析师",
"role": "估值分析师",
"avatar": "valuation",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
}
],
"realtime_prices": {},
"system_started": "2026-03-16T00:38:27.957651",
"feed_history": [
{
"type": "system",
"content": "Runtime assets reloaded.",
"timestamp": "2026-03-16"
},
{
"type": "day_start",
"date": "2026-03-16",
"progress": 0.0,
"timestamp": "2026-03-16"
}
],
"last_day_history": [],
"trading_days_total": 0,
"trading_days_completed": 0,
"server_mode": "live",
"is_backtest": false,
"is_mock_mode": true,
"data_sources": {
"preferred": [
"yfinance",
"financial_datasets",
"finnhub",
"local_csv"
],
"last_success": {
"market_cap": "financial_datasets",
"financial_metrics": "yfinance"
}
},
"last_saved": "2026-03-16T00:38:59.051101"
}

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,134 @@
[
{
"agentId": "portfolio_manager",
"name": "投资经理",
"role": "投资经理",
"avatar": "pm",
"rank": null,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "risk_manager",
"name": "风控经理",
"role": "风控经理",
"avatar": "risk",
"rank": null,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "sentiment_analyst",
"name": "情绪分析师",
"role": "情绪分析师",
"avatar": "sentiment",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "technical_analyst",
"name": "技术分析师",
"role": "技术分析师",
"avatar": "technical",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "fundamentals_analyst",
"name": "基本面分析师",
"role": "基本面分析师",
"avatar": "fundamentals",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
},
{
"agentId": "valuation_analyst",
"name": "估值分析师",
"role": "估值分析师",
"avatar": "valuation",
"rank": 0,
"winRate": null,
"bull": {
"n": 0,
"win": 0,
"unknown": 0
},
"bear": {
"n": 0,
"win": 0,
"unknown": 0
},
"logs": [],
"signals": [],
"modelName": "deepseek-v3.2",
"modelProvider": "DASHSCOPE"
}
]

View File

@@ -0,0 +1,18 @@
{
"totalAssetValue": 250000.0,
"totalReturn": 0.0,
"cashPosition": 250000.0,
"tickerWeights": {},
"totalTrades": 0,
"winRate": 0.0,
"bullBear": {
"bull": {
"n": 0,
"win": 0
},
"bear": {
"n": 0,
"win": 0
}
}
}

View File

@@ -0,0 +1,13 @@
{
"totalAssetValue": 250000.0,
"totalReturn": 0.0,
"cashPosition": 250000.0,
"tickerWeights": {},
"totalTrades": 0,
"pnlPct": 0.0,
"balance": 250000.0,
"equity": [],
"baseline": [],
"baseline_vw": [],
"momentum": []
}

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,13 @@
---
tickers:
- NVDA
initial_cash: 100000
margin_requirement: 0.5
enable_memory: false
max_comm_cycles: 2
agent_overrides: {}
---
# Bootstrap
Prompt reload smoke.

View File

@@ -0,0 +1,2 @@
# POLICY

View File

@@ -0,0 +1,2 @@
# ROLE

View File

@@ -0,0 +1,2 @@
# STYLE

View File

@@ -0,0 +1,2 @@
# POLICY

View File

@@ -0,0 +1,2 @@
# ROLE

View File

@@ -0,0 +1,2 @@
# STYLE

View File

@@ -0,0 +1,2 @@
# POLICY

View File

@@ -0,0 +1,2 @@
# ROLE

View File

@@ -0,0 +1,3 @@
# Style
STYLE_MARKER_TWO

View File

@@ -0,0 +1,2 @@
# POLICY

View File

@@ -0,0 +1,2 @@
# ROLE

View File

@@ -0,0 +1,2 @@
# STYLE

View File

@@ -0,0 +1,2 @@
# POLICY

View File

@@ -0,0 +1,2 @@
# ROLE

View File

@@ -0,0 +1,2 @@
# STYLE

View File

@@ -0,0 +1,2 @@
# POLICY

View File

@@ -0,0 +1,2 @@
# ROLE

View File

@@ -0,0 +1,2 @@
# STYLE

View File

@@ -0,0 +1,4 @@
global_enabled_skills: []
global_disabled_skills: []
agent_enabled_skills: {}
agent_disabled_skills: {}

Some files were not shown because too many files have changed in this diff Show More