1
This commit is contained in:
parent
ac6ff4eb2b
commit
cde11656c8
@ -2,7 +2,7 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.data.tushare_client import tushare_client
|
||||
from app.data import tencent_client
|
||||
@ -96,6 +96,37 @@ async def get_strategy_iteration(limit: int = 50):
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/strategy-configs")
|
||||
async def get_strategy_configs(_admin: dict = Depends(get_current_admin)):
|
||||
"""获取当前策略配置中心状态。"""
|
||||
from app.llm.strategy_config import (
|
||||
get_active_prompt_configs,
|
||||
get_active_strategy_configs,
|
||||
get_recent_config_changes,
|
||||
)
|
||||
|
||||
return {
|
||||
"strategies": await get_active_strategy_configs(),
|
||||
"prompts": await get_active_prompt_configs(),
|
||||
"changes": await get_recent_config_changes(limit=30),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/strategy-configs/{strategy_id}/rollback")
|
||||
async def rollback_strategy_config(strategy_id: str, _admin: dict = Depends(get_current_admin)):
|
||||
"""回滚某个策略到上一配置版本。"""
|
||||
from app.llm.strategy_config import rollback_strategy_config as rollback
|
||||
|
||||
try:
|
||||
result = await rollback(strategy_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
cache.delete("market:strategy_board:rules")
|
||||
cache.delete("market:strategy_iteration:80:rules")
|
||||
cache.delete("market:strategy_iteration:50:rules")
|
||||
return {"status": "ok", "strategy": result}
|
||||
|
||||
|
||||
@router.get("/ops-status")
|
||||
async def get_ops_status():
|
||||
"""管理员任务中心状态与数据新鲜度(只读,不触发扫描或 LLM)。"""
|
||||
@ -182,8 +213,9 @@ async def generate_strategy_board(_admin: dict = Depends(get_current_admin)):
|
||||
async def generate_strategy_iteration(limit: int = 50, _admin: dict = Depends(get_current_admin)):
|
||||
"""管理员手动生成带 LLM 分析的策略复盘"""
|
||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
||||
result = await build_strategy_iteration_report(limit=limit, include_llm=True)
|
||||
result = await build_strategy_iteration_report(limit=limit, include_llm=True, apply_auto_config=True)
|
||||
cache.delete(f"market:strategy_iteration:{limit}:rules")
|
||||
cache.delete("market:strategy_board:rules")
|
||||
return result
|
||||
|
||||
async def _overview_realtime():
|
||||
|
||||
@ -93,6 +93,10 @@ async def init_db():
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN full_analysis TEXT DEFAULT ''",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN score_reference REAL DEFAULT 0",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN analysis_mode TEXT DEFAULT 'scheduled'",
|
||||
"ALTER TABLE strategy_configs ADD COLUMN evidence_json TEXT DEFAULT '{}'",
|
||||
"ALTER TABLE strategy_configs ADD COLUMN effective_from DATETIME DEFAULT CURRENT_TIMESTAMP",
|
||||
"ALTER TABLE prompt_configs ADD COLUMN evidence_json TEXT DEFAULT '{}'",
|
||||
"ALTER TABLE strategy_config_changes ADD COLUMN prompt_key TEXT DEFAULT ''",
|
||||
]:
|
||||
try:
|
||||
await conn.execute(
|
||||
|
||||
@ -195,3 +195,46 @@ error_logs_table = Table(
|
||||
Column("detail", Text, default=""), # 完整异常信息(traceback)
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
strategy_configs_table = Table(
|
||||
"strategy_configs", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("strategy_id", Text, nullable=False),
|
||||
Column("version", Integer, nullable=False, default=1),
|
||||
Column("config_json", Text, nullable=False),
|
||||
Column("is_active", Boolean, default=True),
|
||||
Column("source", Text, default="manual"), # manual / auto_review / rollback / default_seed
|
||||
Column("change_reason", Text, default=""),
|
||||
Column("evidence_json", Text, default="{}"),
|
||||
Column("effective_from", DateTime, server_default=func.now()),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
prompt_configs_table = Table(
|
||||
"prompt_configs", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("prompt_key", Text, nullable=False),
|
||||
Column("version", Integer, nullable=False, default=1),
|
||||
Column("content", Text, nullable=False),
|
||||
Column("is_active", Boolean, default=True),
|
||||
Column("source", Text, default="manual"), # manual / candidate / rollback / default_seed
|
||||
Column("change_reason", Text, default=""),
|
||||
Column("evidence_json", Text, default="{}"),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
strategy_config_changes_table = Table(
|
||||
"strategy_config_changes", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("change_type", Text, nullable=False), # auto_applied / pending / rollback / manual
|
||||
Column("status", Text, default="pending"), # pending / applied / rejected
|
||||
Column("strategy_id", Text, default=""),
|
||||
Column("prompt_key", Text, default=""),
|
||||
Column("base_version", Integer, default=0),
|
||||
Column("new_version", Integer, default=0),
|
||||
Column("diff_json", Text, default="{}"),
|
||||
Column("evidence_json", Text, default="{}"),
|
||||
Column("reason", Text, default=""),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
Column("applied_at", DateTime),
|
||||
)
|
||||
|
||||
@ -51,6 +51,29 @@ async def _run_watchlist_analysis():
|
||||
await log_error("scheduler", f"自选股定时分析失败: {e}", detail=traceback.format_exc())
|
||||
|
||||
|
||||
async def _run_strategy_iteration():
|
||||
"""收盘后生成策略复盘,并允许小幅自动配置调整。"""
|
||||
logger.info("=== 开始策略复盘与配置校准 ===")
|
||||
try:
|
||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
||||
report = await build_strategy_iteration_report(limit=80, include_llm=False, apply_auto_config=True)
|
||||
logger.info(
|
||||
"策略复盘完成: sample=%s auto_change=%s",
|
||||
report.get("sample_size", 0),
|
||||
bool(report.get("auto_config_change")),
|
||||
)
|
||||
await broadcast_update({
|
||||
"type": "strategy_iteration_ready",
|
||||
"sample_size": report.get("sample_size", 0),
|
||||
"auto_config_changed": bool(report.get("auto_config_change")),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"策略复盘自动校准失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("scheduler", f"策略复盘自动校准失败: {e}", detail=traceback.format_exc())
|
||||
|
||||
|
||||
def setup_scheduler():
|
||||
"""配置所有定时任务(交易日时间)"""
|
||||
|
||||
@ -93,6 +116,11 @@ def setup_scheduler():
|
||||
id="watchlist_analysis", replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
_run_strategy_iteration, CronTrigger(hour=16, minute=35, day_of_week="mon-fri"),
|
||||
id="strategy_iteration", replace_existing=True
|
||||
)
|
||||
|
||||
logger.info("盘中调度器已配置完成")
|
||||
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
async def prefilter_single_stock(candidate: dict, market_summary: str) -> dict:
|
||||
"""对单只候选股票做轻量 LLM 预筛。"""
|
||||
from app.llm.prompts import STOCK_PREFILTER_PROMPT
|
||||
from app.llm.strategy_config import get_prompt_content
|
||||
from app.llm.client import get_client
|
||||
|
||||
stock_text = f"""\
|
||||
@ -42,7 +43,8 @@ async def prefilter_single_stock(candidate: dict, market_summary: str) -> dict:
|
||||
if candidate.get("intraday_volume"):
|
||||
stock_text += f"\n\n## 分时量能摘要\n{candidate['intraday_volume']}"
|
||||
|
||||
user_msg = f"{STOCK_PREFILTER_PROMPT}\n\n## 市场环境\n{market_summary}\n\n{stock_text}\n\n请输出 JSON。"
|
||||
prompt = await get_prompt_content("stock_prefilter", STOCK_PREFILTER_PROMPT)
|
||||
user_msg = f"{prompt}\n\n## 市场环境\n{market_summary}\n\n{stock_text}\n\n请输出 JSON。"
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
@ -102,6 +104,7 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
||||
}
|
||||
"""
|
||||
from app.llm.prompts import SINGLE_STOCK_ANALYSIS_PROMPT
|
||||
from app.llm.strategy_config import get_prompt_content
|
||||
from app.llm.client import get_client
|
||||
|
||||
# 构建 prompt — 不传 signal_type,让 LLM 独立判断
|
||||
@ -120,7 +123,8 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
||||
if candidate.get("capital_flow_summary"):
|
||||
stock_text += f"\n\n## 资金流向\n{candidate['capital_flow_summary']}"
|
||||
|
||||
user_msg = f"{SINGLE_STOCK_ANALYSIS_PROMPT}\n\n## 市场环境\n{market_summary}\n\n{stock_text}\n\n请给出你的分析。"
|
||||
prompt = await get_prompt_content("single_stock_analysis", SINGLE_STOCK_ANALYSIS_PROMPT)
|
||||
user_msg = f"{prompt}\n\n## 市场环境\n{market_summary}\n\n{stock_text}\n\n请给出你的分析。"
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
|
||||
@ -187,3 +187,13 @@ STOCK_PREFILTER_PROMPT = """\
|
||||
- confidence 必须是 1-10 整数
|
||||
- focus_points 最多三条,尽量具体
|
||||
- 如果拿不准,优先给 watch,不要滥给 ignore"""
|
||||
|
||||
|
||||
STRATEGY_ITERATION_PROMPT = """\
|
||||
请基于推荐复盘数据,输出策略迭代建议。
|
||||
要求:
|
||||
1. 明确指出最该收紧、保留、加强的策略或信号;
|
||||
2. 只提出可执行调整建议,不要泛泛而谈;
|
||||
3. 不要承诺收益;
|
||||
4. 区分可小幅自动配置的参数调整与需要人工确认的大改动;
|
||||
5. 180字以内。"""
|
||||
|
||||
421
backend/app/llm/strategy_config.py
Normal file
421
backend/app/llm/strategy_config.py
Normal file
@ -0,0 +1,421 @@
|
||||
"""策略配置中心
|
||||
|
||||
把可迭代的策略参数和 Prompt 版本持久化到数据库。
|
||||
代码里的默认策略只作为兜底;一旦数据库有激活配置,下一轮扫描直接读取配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db import tables
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_FIELDS = {
|
||||
"name",
|
||||
"description",
|
||||
"entry_signal_priority",
|
||||
"score_weights",
|
||||
"min_score",
|
||||
"buy_threshold",
|
||||
"max_position_pct",
|
||||
"allow_trading",
|
||||
"actionable_limit",
|
||||
"watch_limit",
|
||||
"target_focus_sectors",
|
||||
"market_stance",
|
||||
"decision_note",
|
||||
"notes",
|
||||
}
|
||||
|
||||
PROMPT_DEFAULT_KEYS = {
|
||||
"stock_prefilter": "STOCK_PREFILTER_PROMPT",
|
||||
"single_stock_analysis": "SINGLE_STOCK_ANALYSIS_PROMPT",
|
||||
"strategy_iteration": "STRATEGY_ITERATION_PROMPT",
|
||||
}
|
||||
|
||||
|
||||
def profile_to_config(profile) -> dict[str, Any]:
|
||||
data = profile.model_dump() if hasattr(profile, "model_dump") else dict(profile)
|
||||
return {key: data[key] for key in CONFIG_FIELDS if key in data}
|
||||
|
||||
|
||||
def apply_config_to_profile(profile, config: dict[str, Any] | None, generated_by: str = "config"):
|
||||
if not config:
|
||||
return profile
|
||||
updated = profile.model_copy(deep=True)
|
||||
for key, value in config.items():
|
||||
if key in CONFIG_FIELDS and hasattr(updated, key):
|
||||
setattr(updated, key, value)
|
||||
updated.generated_by = generated_by
|
||||
return updated
|
||||
|
||||
|
||||
async def load_active_strategy_profile(profile):
|
||||
row = await _load_active_strategy_row(profile.strategy_id)
|
||||
if not row:
|
||||
return profile
|
||||
config = _json_loads(row["config_json"], {})
|
||||
updated = apply_config_to_profile(profile, config, generated_by=f"config:v{row['version']}")
|
||||
updated.feedback_applied = True
|
||||
updated.feedback_notes = [
|
||||
f"策略配置版本 v{row['version']} ({row['source']}) 已生效",
|
||||
row["change_reason"] or "使用配置中心激活版本",
|
||||
]
|
||||
return updated
|
||||
|
||||
|
||||
async def get_active_strategy_configs() -> list[dict]:
|
||||
await ensure_default_configs()
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM strategy_configs "
|
||||
"WHERE is_active = 1 "
|
||||
"ORDER BY strategy_id ASC, version DESC"
|
||||
)
|
||||
)
|
||||
return [_format_strategy_row(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_recent_config_changes(limit: int = 20) -> list[dict]:
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM strategy_config_changes "
|
||||
"ORDER BY id DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": limit},
|
||||
)
|
||||
return [_format_change_row(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_active_prompt_configs() -> list[dict]:
|
||||
await ensure_default_configs()
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM prompt_configs "
|
||||
"WHERE is_active = 1 "
|
||||
"ORDER BY prompt_key ASC, version DESC"
|
||||
)
|
||||
)
|
||||
return [_format_prompt_row(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_prompt_content(prompt_key: str, default: str) -> str:
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT content FROM prompt_configs "
|
||||
"WHERE prompt_key = :key AND is_active = 1 "
|
||||
"ORDER BY version DESC LIMIT 1"
|
||||
),
|
||||
{"key": prompt_key},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return str(row._mapping["content"]) if row else default
|
||||
|
||||
|
||||
async def create_active_strategy_config(
|
||||
strategy_id: str,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
source: str,
|
||||
reason: str,
|
||||
evidence: dict[str, Any] | None = None,
|
||||
change_type: str = "manual",
|
||||
) -> dict:
|
||||
"""写入一个新的激活策略配置版本,并记录变更。"""
|
||||
async with get_db() as db:
|
||||
base = await _load_active_strategy_row(strategy_id, db=db)
|
||||
version = int(base["version"]) + 1 if base else 1
|
||||
before = _json_loads(base["config_json"], {}) if base else {}
|
||||
diff = _build_diff(before, config)
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE strategy_configs SET is_active = 0 WHERE strategy_id = :sid"),
|
||||
{"sid": strategy_id},
|
||||
)
|
||||
await db.execute(
|
||||
tables.strategy_configs_table.insert().values(
|
||||
strategy_id=strategy_id,
|
||||
version=version,
|
||||
config_json=json.dumps(config, ensure_ascii=False),
|
||||
is_active=True,
|
||||
source=source,
|
||||
change_reason=reason,
|
||||
evidence_json=json.dumps(evidence or {}, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
tables.strategy_config_changes_table.insert().values(
|
||||
change_type=change_type,
|
||||
status="applied",
|
||||
strategy_id=strategy_id,
|
||||
base_version=int(base["version"]) if base else 0,
|
||||
new_version=version,
|
||||
diff_json=json.dumps(diff, ensure_ascii=False),
|
||||
evidence_json=json.dumps(evidence or {}, ensure_ascii=False),
|
||||
reason=reason,
|
||||
applied_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
row = await _load_active_strategy_row(strategy_id)
|
||||
return _format_strategy_row(row)
|
||||
|
||||
|
||||
async def rollback_strategy_config(strategy_id: str) -> dict:
|
||||
"""回滚到当前策略的上一个版本。"""
|
||||
async with get_db() as db:
|
||||
active = await _load_active_strategy_row(strategy_id, db=db)
|
||||
if not active:
|
||||
raise ValueError("当前策略没有激活配置")
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM strategy_configs "
|
||||
"WHERE strategy_id = :sid AND version < :version "
|
||||
"ORDER BY version DESC LIMIT 1"
|
||||
),
|
||||
{"sid": strategy_id, "version": active["version"]},
|
||||
)
|
||||
previous_row = result.fetchone()
|
||||
if not previous_row:
|
||||
raise ValueError("没有可回滚的上一版本")
|
||||
current = active
|
||||
previous = previous_row._mapping
|
||||
await db.execute(
|
||||
text("UPDATE strategy_configs SET is_active = 0 WHERE strategy_id = :sid"),
|
||||
{"sid": strategy_id},
|
||||
)
|
||||
await db.execute(
|
||||
text("UPDATE strategy_configs SET is_active = 1, source = 'rollback' WHERE id = :id"),
|
||||
{"id": previous["id"]},
|
||||
)
|
||||
await db.execute(
|
||||
tables.strategy_config_changes_table.insert().values(
|
||||
change_type="rollback",
|
||||
status="applied",
|
||||
strategy_id=strategy_id,
|
||||
base_version=int(current["version"]),
|
||||
new_version=int(previous["version"]),
|
||||
diff_json=json.dumps(
|
||||
_build_diff(_json_loads(current["config_json"], {}), _json_loads(previous["config_json"], {})),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
reason=f"回滚 {strategy_id} 到 v{previous['version']}",
|
||||
applied_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
row = await _load_active_strategy_row(strategy_id)
|
||||
return _format_strategy_row(row)
|
||||
|
||||
|
||||
async def ensure_default_configs() -> None:
|
||||
"""首次启动时把代码默认策略和默认 Prompt 种子写入数据库。"""
|
||||
from app.llm.strategy_selector import get_strategy_profile_by_id
|
||||
from app.llm import prompts
|
||||
|
||||
strategy_ids = ["breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"]
|
||||
async with get_db() as db:
|
||||
for strategy_id in strategy_ids:
|
||||
count = (await db.execute(
|
||||
text("SELECT COUNT(*) FROM strategy_configs WHERE strategy_id = :sid"),
|
||||
{"sid": strategy_id},
|
||||
)).scalar() or 0
|
||||
if count:
|
||||
continue
|
||||
profile = get_strategy_profile_by_id(strategy_id)
|
||||
await db.execute(
|
||||
tables.strategy_configs_table.insert().values(
|
||||
strategy_id=strategy_id,
|
||||
version=1,
|
||||
config_json=json.dumps(profile_to_config(profile), ensure_ascii=False),
|
||||
is_active=True,
|
||||
source="default_seed",
|
||||
change_reason="初始化默认策略配置",
|
||||
)
|
||||
)
|
||||
|
||||
prompt_defaults = {
|
||||
"stock_prefilter": getattr(prompts, "STOCK_PREFILTER_PROMPT", ""),
|
||||
"single_stock_analysis": getattr(prompts, "SINGLE_STOCK_ANALYSIS_PROMPT", ""),
|
||||
"strategy_iteration": getattr(prompts, "STRATEGY_ITERATION_PROMPT", ""),
|
||||
}
|
||||
for prompt_key, content in prompt_defaults.items():
|
||||
if not content:
|
||||
continue
|
||||
count = (await db.execute(
|
||||
text("SELECT COUNT(*) FROM prompt_configs WHERE prompt_key = :key"),
|
||||
{"key": prompt_key},
|
||||
)).scalar() or 0
|
||||
if count:
|
||||
continue
|
||||
await db.execute(
|
||||
tables.prompt_configs_table.insert().values(
|
||||
prompt_key=prompt_key,
|
||||
version=1,
|
||||
content=content,
|
||||
is_active=True,
|
||||
source="default_seed",
|
||||
change_reason="初始化默认 Prompt 配置",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def maybe_auto_apply_review_adjustment(report: dict) -> dict | None:
|
||||
"""根据复盘报告做小幅自动配置调整。
|
||||
|
||||
大幅结构调整仍只进入报告建议,不自动改配置。
|
||||
"""
|
||||
sample_size = int(report.get("sample_size") or 0)
|
||||
if sample_size < 10:
|
||||
return None
|
||||
if await _has_auto_change_today():
|
||||
return None
|
||||
|
||||
for suggestion in report.get("adjustment_suggestions", []) or []:
|
||||
strategy_id = suggestion.get("target", "")
|
||||
if strategy_id not in {"breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"}:
|
||||
continue
|
||||
active = await _load_active_strategy_row(strategy_id)
|
||||
if not active:
|
||||
continue
|
||||
config = _json_loads(active["config_json"], {})
|
||||
changed = _apply_small_adjustment(config, suggestion.get("action", ""))
|
||||
if not changed:
|
||||
continue
|
||||
evidence = {
|
||||
"sample_size": sample_size,
|
||||
"summary": report.get("summary", ""),
|
||||
"suggestion": suggestion,
|
||||
}
|
||||
return await create_active_strategy_config(
|
||||
strategy_id,
|
||||
config,
|
||||
source="auto_review",
|
||||
reason=suggestion.get("reason", "复盘触发小幅自动配置调整"),
|
||||
evidence=evidence,
|
||||
change_type="auto_applied",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _load_active_strategy_row(strategy_id: str, db=None):
|
||||
own_session = db is None
|
||||
if own_session:
|
||||
async with get_db() as session:
|
||||
return await _load_active_strategy_row(strategy_id, db=session)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM strategy_configs "
|
||||
"WHERE strategy_id = :sid AND is_active = 1 "
|
||||
"ORDER BY version DESC LIMIT 1"
|
||||
),
|
||||
{"sid": strategy_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row._mapping if row else None
|
||||
|
||||
|
||||
async def _has_auto_change_today() -> bool:
|
||||
async with get_db() as db:
|
||||
count = (await db.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM strategy_config_changes "
|
||||
"WHERE change_type = 'auto_applied' "
|
||||
"AND date(created_at) = date('now', 'localtime')"
|
||||
)
|
||||
)).scalar() or 0
|
||||
return count > 0
|
||||
|
||||
|
||||
def _apply_small_adjustment(config: dict[str, Any], action: str) -> bool:
|
||||
if action == "tighten":
|
||||
config["buy_threshold"] = min(float(config.get("buy_threshold", 60)) + 1, 80)
|
||||
config["max_position_pct"] = max(float(config.get("max_position_pct", 10)) - 5, 0)
|
||||
config["actionable_limit"] = max(int(config.get("actionable_limit", 1)) - 1, 0)
|
||||
return True
|
||||
if action == "promote":
|
||||
config["buy_threshold"] = max(float(config.get("buy_threshold", 60)) - 1, float(config.get("min_score", 0)))
|
||||
config["watch_limit"] = min(int(config.get("watch_limit", 3)) + 1, 8)
|
||||
return True
|
||||
if action == "reduce":
|
||||
config["buy_threshold"] = min(float(config.get("buy_threshold", 60)) + 1, 80)
|
||||
config["watch_limit"] = max(int(config.get("watch_limit", 3)) - 1, 1)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
diff: dict[str, dict[str, Any]] = {}
|
||||
for key in sorted(set(before) | set(after)):
|
||||
if before.get(key) != after.get(key):
|
||||
diff[key] = {"from": before.get(key), "to": after.get(key)}
|
||||
return diff
|
||||
|
||||
|
||||
def _json_loads(value: str | None, default):
|
||||
try:
|
||||
return json.loads(value or "")
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _format_strategy_row(row) -> dict:
|
||||
if not row:
|
||||
return {}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"strategy_id": row["strategy_id"],
|
||||
"version": row["version"],
|
||||
"config": _json_loads(row["config_json"], {}),
|
||||
"is_active": bool(row["is_active"]),
|
||||
"source": row["source"] or "",
|
||||
"change_reason": row["change_reason"] or "",
|
||||
"evidence": _json_loads(row["evidence_json"], {}),
|
||||
"effective_from": str(row["effective_from"] or ""),
|
||||
"created_at": str(row["created_at"] or ""),
|
||||
}
|
||||
|
||||
|
||||
def _format_prompt_row(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"prompt_key": row["prompt_key"],
|
||||
"version": row["version"],
|
||||
"content": row["content"],
|
||||
"is_active": bool(row["is_active"]),
|
||||
"source": row["source"] or "",
|
||||
"change_reason": row["change_reason"] or "",
|
||||
"evidence": _json_loads(row["evidence_json"], {}),
|
||||
"created_at": str(row["created_at"] or ""),
|
||||
}
|
||||
|
||||
|
||||
def _format_change_row(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"change_type": row["change_type"],
|
||||
"status": row["status"],
|
||||
"strategy_id": row["strategy_id"] or "",
|
||||
"prompt_key": row["prompt_key"] or "",
|
||||
"base_version": row["base_version"] or 0,
|
||||
"new_version": row["new_version"] or 0,
|
||||
"diff": _json_loads(row["diff_json"], {}),
|
||||
"evidence": _json_loads(row["evidence_json"], {}),
|
||||
"reason": row["reason"] or "",
|
||||
"created_at": str(row["created_at"] or ""),
|
||||
"applied_at": str(row["applied_at"] or ""),
|
||||
}
|
||||
@ -14,10 +14,25 @@ from app.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def build_strategy_iteration_report(limit: int = 50, include_llm: bool = False) -> dict:
|
||||
async def build_strategy_iteration_report(
|
||||
limit: int = 50,
|
||||
include_llm: bool = False,
|
||||
apply_auto_config: bool = False,
|
||||
) -> dict:
|
||||
rows = await _load_recent_tracking(limit)
|
||||
rule_report = _build_rule_report(rows)
|
||||
|
||||
auto_change = None
|
||||
if apply_auto_config:
|
||||
from app.llm.strategy_config import maybe_auto_apply_review_adjustment
|
||||
try:
|
||||
auto_change = await maybe_auto_apply_review_adjustment(rule_report)
|
||||
except Exception as e:
|
||||
logger.warning(f"自动策略配置调整失败: {e}")
|
||||
if auto_change:
|
||||
rule_report["auto_config_change"] = auto_change
|
||||
rule_report["generated_by"] = "rules+auto_config"
|
||||
|
||||
if include_llm and settings.deepseek_api_key and rows:
|
||||
ai_text = await _generate_ai_iteration(rule_report, rows)
|
||||
if ai_text:
|
||||
@ -43,6 +58,8 @@ async def _load_recent_tracking(limit: int) -> list[dict]:
|
||||
r_action_plan = _column_or_default(rec_columns, "action_plan", "'观察'", "r")
|
||||
r_position_score = _column_or_default(rec_columns, "position_score", "50", "r")
|
||||
r_lifecycle_status = _column_or_default(rec_columns, "lifecycle_status", "'candidate'", "r")
|
||||
r_capital_score = _column_or_default(rec_columns, "capital_score", "0", "r")
|
||||
r_recall_tags = _column_or_default(rec_columns, "recall_tags", "'[]'", "r")
|
||||
t_max_return = _column_or_default(tracking_columns, "max_return_pct", "t.pct_from_entry", "t")
|
||||
t_max_drawdown = _column_or_default(tracking_columns, "max_drawdown_pct", "t.pct_from_entry", "t")
|
||||
t_days_since = _column_or_default(tracking_columns, "days_since_recommendation", "0", "t")
|
||||
@ -53,7 +70,9 @@ async def _load_recent_tracking(limit: int) -> list[dict]:
|
||||
text(
|
||||
"SELECT r.id, r.ts_code, r.name, r.sector, r.strategy, r.entry_signal_type, "
|
||||
f"{r_action_plan} AS action_plan, r.score, r.market_temp_score, r.sector_score, "
|
||||
f"{r_position_score} AS position_score, {r_lifecycle_status} AS lifecycle_status, r.created_at, "
|
||||
f"{r_capital_score} AS capital_score, {r_position_score} AS position_score, "
|
||||
f"{r_lifecycle_status} AS lifecycle_status, {r_recall_tags} AS recall_tags, "
|
||||
"r.entry_price, r.target_price, r.stop_loss, r.created_at, "
|
||||
f"t.pct_from_entry, {t_max_return} AS max_return_pct, {t_max_drawdown} AS max_drawdown_pct, "
|
||||
f"{t_days_since} AS days_since_recommendation, {t_close_reason} AS close_reason, "
|
||||
f"{t_review_note} AS review_note, t.track_date "
|
||||
@ -94,7 +113,12 @@ def _build_rule_report(rows: list[dict]) -> dict:
|
||||
"strategy_stats": [],
|
||||
"signal_stats": [],
|
||||
"failure_patterns": ["样本不足,先积累推荐生命周期数据。"],
|
||||
"review_windows": [],
|
||||
"failure_cases": [],
|
||||
"success_patterns": [],
|
||||
"adjustment_suggestions": [],
|
||||
"agent_patch_prompts": [],
|
||||
"auto_config_change": None,
|
||||
"ai_analysis": "",
|
||||
"generated_by": "rules",
|
||||
}
|
||||
@ -104,6 +128,16 @@ def _build_rule_report(rows: list[dict]) -> dict:
|
||||
signal_stats = _group_stats(tracked_rows, "entry_signal_type")
|
||||
failure_patterns = _detect_failure_patterns(tracked_rows)
|
||||
suggestions = _build_adjustment_suggestions(strategy_stats, signal_stats, failure_patterns, len(tracked_rows))
|
||||
review_windows = _build_review_windows(tracked_rows)
|
||||
failure_cases = _build_failure_cases(tracked_rows)
|
||||
success_patterns = _build_success_patterns(tracked_rows)
|
||||
patch_prompts = _build_agent_patch_prompts(
|
||||
strategy_stats=strategy_stats,
|
||||
signal_stats=signal_stats,
|
||||
failure_patterns=failure_patterns,
|
||||
failure_cases=failure_cases,
|
||||
sample_size=len(tracked_rows),
|
||||
)
|
||||
|
||||
wins = sum(1 for r in tracked_rows if (r.get("pct_from_entry") or 0) > 0)
|
||||
avg_return = _avg([r.get("pct_from_entry") for r in tracked_rows])
|
||||
@ -120,7 +154,12 @@ def _build_rule_report(rows: list[dict]) -> dict:
|
||||
"strategy_stats": strategy_stats,
|
||||
"signal_stats": signal_stats,
|
||||
"failure_patterns": failure_patterns,
|
||||
"review_windows": review_windows,
|
||||
"failure_cases": failure_cases,
|
||||
"success_patterns": success_patterns,
|
||||
"adjustment_suggestions": suggestions,
|
||||
"agent_patch_prompts": patch_prompts,
|
||||
"auto_config_change": None,
|
||||
"ai_analysis": "",
|
||||
"generated_by": "rules",
|
||||
}
|
||||
@ -246,6 +285,192 @@ def _build_adjustment_suggestions(
|
||||
return suggestions[:6]
|
||||
|
||||
|
||||
def _build_review_windows(rows: list[dict]) -> list[dict]:
|
||||
windows = []
|
||||
for days in [3, 5, 10]:
|
||||
items = [
|
||||
r for r in rows
|
||||
if int(r.get("days_since_recommendation") or 0) >= days
|
||||
]
|
||||
if not items:
|
||||
windows.append({
|
||||
"window_days": days,
|
||||
"count": 0,
|
||||
"win_rate": 0,
|
||||
"avg_return": 0,
|
||||
"hit_target_rate": 0,
|
||||
"hit_stop_rate": 0,
|
||||
"avg_max_return": 0,
|
||||
"avg_max_drawdown": 0,
|
||||
})
|
||||
continue
|
||||
wins = sum(1 for r in items if (r.get("pct_from_entry") or 0) > 0)
|
||||
hit_target = sum(1 for r in items if r.get("close_reason") == "hit_target")
|
||||
hit_stop = sum(1 for r in items if r.get("close_reason") == "hit_stop_loss")
|
||||
count = len(items)
|
||||
windows.append({
|
||||
"window_days": days,
|
||||
"count": count,
|
||||
"win_rate": round(wins / count * 100, 1),
|
||||
"avg_return": _avg([r.get("pct_from_entry") for r in items]),
|
||||
"hit_target_rate": round(hit_target / count * 100, 1),
|
||||
"hit_stop_rate": round(hit_stop / count * 100, 1),
|
||||
"avg_max_return": _avg([r.get("max_return_pct") for r in items]),
|
||||
"avg_max_drawdown": _avg([r.get("max_drawdown_pct") for r in items]),
|
||||
})
|
||||
return windows
|
||||
|
||||
|
||||
def _build_failure_cases(rows: list[dict]) -> list[dict]:
|
||||
failures = [
|
||||
r for r in rows
|
||||
if (r.get("pct_from_entry") or 0) < 0
|
||||
or (r.get("close_reason") in {"hit_stop_loss", "review_expired_loss", "review_expired_flat"})
|
||||
or (r.get("max_drawdown_pct") or 0) < -5
|
||||
]
|
||||
failures.sort(key=lambda r: ((r.get("pct_from_entry") or 0), (r.get("max_drawdown_pct") or 0)))
|
||||
return [_case_summary(r) for r in failures[:8]]
|
||||
|
||||
|
||||
def _build_success_patterns(rows: list[dict]) -> list[dict]:
|
||||
successes = [
|
||||
r for r in rows
|
||||
if r.get("close_reason") == "hit_target"
|
||||
or (r.get("max_return_pct") or 0) >= 3
|
||||
or (r.get("pct_from_entry") or 0) > 2
|
||||
]
|
||||
successes.sort(key=lambda r: (r.get("max_return_pct") or 0), reverse=True)
|
||||
return [_case_summary(r) for r in successes[:8]]
|
||||
|
||||
|
||||
def _case_summary(row: dict) -> dict:
|
||||
recall_tags = row.get("recall_tags") or "[]"
|
||||
try:
|
||||
tags = json.loads(recall_tags) if isinstance(recall_tags, str) else recall_tags
|
||||
except Exception:
|
||||
tags = []
|
||||
return {
|
||||
"ts_code": row.get("ts_code"),
|
||||
"name": row.get("name"),
|
||||
"sector": row.get("sector") or "",
|
||||
"strategy": row.get("strategy") or "unknown",
|
||||
"entry_signal_type": row.get("entry_signal_type") or "unknown",
|
||||
"action_plan": row.get("action_plan") or "观察",
|
||||
"score": row.get("score") or 0,
|
||||
"market_temp_score": row.get("market_temp_score") or 0,
|
||||
"sector_score": row.get("sector_score") or 0,
|
||||
"capital_score": row.get("capital_score") or 0,
|
||||
"position_score": row.get("position_score") or 50,
|
||||
"pct_from_entry": row.get("pct_from_entry") or 0,
|
||||
"max_return_pct": row.get("max_return_pct") or 0,
|
||||
"max_drawdown_pct": row.get("max_drawdown_pct") or 0,
|
||||
"days_since_recommendation": row.get("days_since_recommendation") or 0,
|
||||
"close_reason": row.get("close_reason") or "",
|
||||
"review_note": row.get("review_note") or "",
|
||||
"recall_tags": tags,
|
||||
}
|
||||
|
||||
|
||||
def _build_agent_patch_prompts(
|
||||
strategy_stats: list[dict],
|
||||
signal_stats: list[dict],
|
||||
failure_patterns: list[str],
|
||||
failure_cases: list[dict],
|
||||
sample_size: int,
|
||||
) -> list[dict]:
|
||||
if sample_size < 10:
|
||||
return []
|
||||
|
||||
prompts = []
|
||||
weak_strategy = next(
|
||||
(
|
||||
s for s in strategy_stats
|
||||
if s["count"] >= 3 and s["win_rate"] < 40 and s["avg_return"] < 0
|
||||
),
|
||||
None,
|
||||
)
|
||||
if weak_strategy:
|
||||
prompts.append(_patch_prompt(
|
||||
title=f"收紧 {weak_strategy['name']} 策略配置",
|
||||
severity="high",
|
||||
evidence=f"样本{weak_strategy['count']}条,胜率{weak_strategy['win_rate']}%,平均收益{weak_strategy['avg_return']}%。",
|
||||
target_files=["backend/app/llm/strategy_config.py", "backend/app/llm/strategy_selector.py"],
|
||||
prompt=(
|
||||
f"请基于策略复盘收紧 {weak_strategy['name']}。优先通过策略配置版本调整完成,不要改无关代码。"
|
||||
f"证据:{weak_strategy['count']}条样本,胜率{weak_strategy['win_rate']}%,平均收益{weak_strategy['avg_return']}%,"
|
||||
f"平均最大回撤{weak_strategy['avg_max_drawdown']}%。"
|
||||
"请提高 buy_threshold 1-2 分,降低 actionable_limit 或 max_position_pct,并保留回滚记录。"
|
||||
),
|
||||
))
|
||||
|
||||
weak_signal = next(
|
||||
(
|
||||
s for s in signal_stats
|
||||
if s["count"] >= 3 and s["avg_max_drawdown"] < -5
|
||||
),
|
||||
None,
|
||||
)
|
||||
if weak_signal:
|
||||
prompts.append(_patch_prompt(
|
||||
title=f"降低 {weak_signal['name']} 信号风险暴露",
|
||||
severity="medium",
|
||||
evidence=f"样本{weak_signal['count']}条,平均最大回撤{weak_signal['avg_max_drawdown']}%。",
|
||||
target_files=["backend/app/engine/screener.py", "backend/app/analysis/breakout_signals.py"],
|
||||
prompt=(
|
||||
f"请基于复盘结果降低 {weak_signal['name']} 信号的风险暴露。"
|
||||
f"证据:样本{weak_signal['count']}条,平均最大回撤{weak_signal['avg_max_drawdown']}%,"
|
||||
f"命中止损{weak_signal['hit_stop']}次。"
|
||||
"请检查该信号的入场质量、位置过滤和止损设置,给出最小代码补丁,并保持其他信号行为不变。"
|
||||
),
|
||||
))
|
||||
|
||||
if any("弱势市场" in p for p in failure_patterns):
|
||||
prompts.append(_patch_prompt(
|
||||
title="强化弱势市场防守配置",
|
||||
severity="high",
|
||||
evidence="复盘显示弱势市场亏损样本集中。",
|
||||
target_files=["backend/app/llm/strategy_config.py", "backend/app/llm/strategy_selector.py"],
|
||||
prompt=(
|
||||
"请强化弱势市场防守配置。证据:复盘显示市场温度低于45时亏损样本集中。"
|
||||
"优先把低温环境下的 allow_trading、actionable_limit、buy_threshold 做成可配置护栏,"
|
||||
"小幅收紧可自动生效,大幅禁用策略需生成待确认变更。"
|
||||
),
|
||||
))
|
||||
|
||||
if failure_cases and not prompts:
|
||||
worst = failure_cases[0]
|
||||
prompts.append(_patch_prompt(
|
||||
title="复查推荐失效样本的共同过滤条件",
|
||||
severity="medium",
|
||||
evidence=f"最差样本 {worst['name']} 收益{worst['pct_from_entry']}%,最大回撤{worst['max_drawdown_pct']}%。",
|
||||
target_files=["backend/app/engine/screener.py", "backend/app/llm/strategy_iteration.py"],
|
||||
prompt=(
|
||||
"请复查最近推荐失效样本的共同过滤条件,优先寻找可配置化的收紧项。"
|
||||
f"最差样本:{worst['name']}({worst['ts_code']}),策略{worst['strategy']},"
|
||||
f"信号{worst['entry_signal_type']},收益{worst['pct_from_entry']}%,"
|
||||
f"最大回撤{worst['max_drawdown_pct']}%。"
|
||||
"请不要凭单一样本大改策略,必须保留样本数门槛。"
|
||||
),
|
||||
))
|
||||
|
||||
return prompts[:4]
|
||||
|
||||
|
||||
def _patch_prompt(title: str, severity: str, evidence: str, target_files: list[str], prompt: str) -> dict:
|
||||
return {
|
||||
"title": title,
|
||||
"severity": severity,
|
||||
"evidence": evidence,
|
||||
"target_files": target_files,
|
||||
"prompt": prompt,
|
||||
"acceptance_criteria": [
|
||||
"python3 -m compileall backend/app 通过",
|
||||
"策略配置变更有版本记录且可回滚",
|
||||
"历史推荐和跟踪数据读取不受影响",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _derive_feedback_controls(report: dict) -> dict:
|
||||
suggestions = report.get("adjustment_suggestions", []) or []
|
||||
sample_size = int(report.get("sample_size") or 0)
|
||||
@ -309,6 +534,8 @@ def _derive_feedback_controls(report: dict) -> dict:
|
||||
|
||||
async def _generate_ai_iteration(rule_report: dict, rows: list[dict]) -> str:
|
||||
from app.llm.client import chat_completion
|
||||
from app.llm.prompts import STRATEGY_ITERATION_PROMPT
|
||||
from app.llm.strategy_config import get_prompt_content
|
||||
|
||||
sample = [
|
||||
{
|
||||
@ -325,12 +552,8 @@ async def _generate_ai_iteration(rule_report: dict, rows: list[dict]) -> str:
|
||||
for r in rows[:20]
|
||||
]
|
||||
|
||||
user_msg = f"""请基于以下推荐复盘数据,输出策略迭代建议。
|
||||
要求:
|
||||
1. 明确指出最该收紧、保留、加强的策略或信号;
|
||||
2. 只提出可执行调整建议,不要泛泛而谈;
|
||||
3. 不要承诺收益;
|
||||
4. 180字以内。
|
||||
prompt = await get_prompt_content("strategy_iteration", STRATEGY_ITERATION_PROMPT)
|
||||
user_msg = f"""{prompt}
|
||||
|
||||
规则复盘:
|
||||
{json.dumps(rule_report, ensure_ascii=False)}
|
||||
|
||||
@ -122,13 +122,15 @@ async def select_strategy_profile(
|
||||
hot_sectors: list[SectorInfo],
|
||||
intraday: bool,
|
||||
) -> StrategyProfile:
|
||||
from app.llm.strategy_config import load_active_strategy_profile
|
||||
|
||||
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
|
||||
profile = await _apply_strategy_feedback(profile)
|
||||
profile = await load_active_strategy_profile(profile)
|
||||
|
||||
if settings.deepseek_api_key:
|
||||
llm_profile = await _select_llm_profile(market_temp, hot_sectors, intraday, profile)
|
||||
if llm_profile:
|
||||
profile = llm_profile
|
||||
profile = await load_active_strategy_profile(llm_profile)
|
||||
|
||||
return profile
|
||||
|
||||
|
||||
@ -99,6 +99,9 @@ async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
logger.info("数据库初始化完成")
|
||||
await ensure_admin_exists()
|
||||
from app.llm.strategy_config import ensure_default_configs
|
||||
await ensure_default_configs()
|
||||
logger.info("策略配置中心初始化完成")
|
||||
start_scheduler()
|
||||
logger.info("调度器已启动")
|
||||
yield
|
||||
|
||||
@ -2,10 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import { fetchAPI, postAPI } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import type {
|
||||
AgentPatchPrompt,
|
||||
PerformanceStats,
|
||||
StrategyConfigCenter,
|
||||
StrategyConfigChange,
|
||||
StrategyConfigRecord,
|
||||
StrategyAdjustment,
|
||||
StrategyIterationReport,
|
||||
StrategyStat,
|
||||
@ -24,21 +28,56 @@ export default function StrategyPage() {
|
||||
const router = useRouter();
|
||||
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
|
||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||
const [configCenter, setConfigCenter] = useState<StrategyConfigCenter | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [copiedPrompt, setCopiedPrompt] = useState("");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [iterationReport, perf] = await Promise.all([
|
||||
const [iterationReport, perf, configs] = await Promise.all([
|
||||
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=80").catch(() => null),
|
||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||
fetchAPI<StrategyConfigCenter>("/api/market/strategy-configs").catch(() => null),
|
||||
]);
|
||||
setIteration(iterationReport);
|
||||
setPerformance(perf);
|
||||
setConfigCenter(configs);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateIteration = useCallback(async () => {
|
||||
setActionMessage("正在生成策略复盘...");
|
||||
try {
|
||||
const report = await postAPI<StrategyIterationReport>("/api/market/generate-strategy-iteration?limit=80");
|
||||
const configs = await fetchAPI<StrategyConfigCenter>("/api/market/strategy-configs");
|
||||
setIteration(report);
|
||||
setConfigCenter(configs);
|
||||
setActionMessage(report.auto_config_change ? "已生成复盘,并自动写入一条小幅配置调整。" : "已生成复盘,本次没有触发自动配置调整。");
|
||||
} catch (e) {
|
||||
setActionMessage(e instanceof Error ? e.message : "策略复盘生成失败");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rollbackConfig = useCallback(async (strategyId: string) => {
|
||||
setActionMessage(`正在回滚 ${strategyId}...`);
|
||||
try {
|
||||
await postAPI(`/api/market/strategy-configs/${strategyId}/rollback`);
|
||||
await loadData();
|
||||
setActionMessage(`${strategyId} 已回滚到上一版本。`);
|
||||
} catch (e) {
|
||||
setActionMessage(e instanceof Error ? e.message : "回滚失败");
|
||||
}
|
||||
}, [loadData]);
|
||||
|
||||
const copyPatchPrompt = useCallback(async (item: AgentPatchPrompt) => {
|
||||
await navigator.clipboard.writeText(item.prompt);
|
||||
setCopiedPrompt(item.title);
|
||||
window.setTimeout(() => setCopiedPrompt(""), 1800);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user?.role !== "admin") {
|
||||
router.replace("/dashboard");
|
||||
@ -71,6 +110,22 @@ export default function StrategyPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-3 animate-fade-in-up flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary">配置化自我迭代</div>
|
||||
<div className="mt-1 text-xs text-text-muted">小幅参数调整可自动生效;大改动保留为 Agent 提示词和人工确认。</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{actionMessage ? <span className="text-xs text-amber-400">{actionMessage}</span> : null}
|
||||
<button
|
||||
onClick={generateIteration}
|
||||
className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-400 transition-colors hover:bg-amber-500/15"
|
||||
>
|
||||
生成复盘并校准
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4">
|
||||
<div>
|
||||
@ -136,6 +191,13 @@ export default function StrategyPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfigCenterPanel
|
||||
configs={configCenter}
|
||||
onRollback={rollbackConfig}
|
||||
/>
|
||||
|
||||
<ReviewWindowsPanel windows={iteration.review_windows ?? []} />
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title="下一轮系统指令" />
|
||||
@ -164,6 +226,12 @@ export default function StrategyPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AgentPatchPromptPanel
|
||||
prompts={iteration.agent_patch_prompts ?? []}
|
||||
copiedTitle={copiedPrompt}
|
||||
onCopy={copyPatchPrompt}
|
||||
/>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="最近的失效模式" />
|
||||
<div className="mt-2 text-xs text-text-muted">
|
||||
@ -255,6 +323,210 @@ function DecisionList({
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigCenterPanel({
|
||||
configs,
|
||||
onRollback,
|
||||
}: {
|
||||
configs: StrategyConfigCenter | null;
|
||||
onRollback: (strategyId: string) => void;
|
||||
}) {
|
||||
const strategies = configs?.strategies ?? [];
|
||||
const prompts = configs?.prompts ?? [];
|
||||
const changes = configs?.changes ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_420px] gap-4">
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title="当前策略配置版本" />
|
||||
<span className="text-xs text-text-muted">下一轮扫描直接读取</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{strategies.length ? strategies.map((item) => (
|
||||
<StrategyConfigCard key={item.strategy_id} item={item} onRollback={onRollback} />
|
||||
)) : (
|
||||
<div className="text-sm text-text-muted">暂无配置数据。</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 border-t border-border-subtle pt-4">
|
||||
<div className="text-[11px] font-semibold text-text-secondary">Prompt 版本</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{prompts.length ? prompts.map((item) => (
|
||||
<span key={item.prompt_key} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
||||
{item.prompt_key} · v{item.version}
|
||||
</span>
|
||||
)) : (
|
||||
<span className="text-xs text-text-muted">暂无 Prompt 配置。</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="最近配置变更" />
|
||||
<div className="mt-4 space-y-3">
|
||||
{changes.length ? changes.slice(0, 6).map((item) => (
|
||||
<ConfigChangeRow key={item.id} item={item} />
|
||||
)) : (
|
||||
<div className="text-sm text-text-muted">暂无变更记录。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyConfigCard({ item, onRollback }: { item: StrategyConfigRecord; onRollback: (strategyId: string) => void }) {
|
||||
const cfg = item.config;
|
||||
const scoreWeights = cfg.score_weights as Record<string, number> | undefined;
|
||||
const weightText = scoreWeights
|
||||
? Object.entries(scoreWeights).map(([key, value]) => `${key}:${Number(value).toFixed(2)}`).join(" / ")
|
||||
: "暂无权重";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-text-primary">{item.strategy_id}</div>
|
||||
<div className="mt-1 text-[11px] text-text-muted">v{item.version} · {item.source}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRollback(item.strategy_id)}
|
||||
className="shrink-0 rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-secondary transition-colors hover:border-amber-500/20 hover:text-amber-400"
|
||||
>
|
||||
回滚
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[10px]">
|
||||
<StatCell label="买入阈值" value={String(cfg.buy_threshold ?? "-")} />
|
||||
<StatCell label="最低分" value={String(cfg.min_score ?? "-")} />
|
||||
<StatCell label="仓位上限" value={`${cfg.max_position_pct ?? "-"}%`} />
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2 text-[11px] leading-5 text-text-muted">
|
||||
{weightText}
|
||||
</div>
|
||||
{item.change_reason ? (
|
||||
<div className="mt-2 text-xs leading-5 text-text-secondary">{item.change_reason}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigChangeRow({ item }: { item: StrategyConfigChange }) {
|
||||
const diffEntries = Object.entries(item.diff ?? {}).slice(0, 4);
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary">{item.strategy_id || item.prompt_key || "配置变更"}</div>
|
||||
<div className="mt-1 text-[11px] text-text-muted">
|
||||
{item.change_type} · v{item.base_version} → v{item.new_version}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-text-secondary">{item.reason || "暂无说明"}</div>
|
||||
{diffEntries.length ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{diffEntries.map(([key, value]) => (
|
||||
<div key={`${item.id}-${key}`} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
|
||||
{key}: {formatUnknown(value.from)} → {formatUnknown(value.to)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewWindowsPanel({ windows }: { windows: NonNullable<StrategyIterationReport["review_windows"]> }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="3/5/10 日复盘窗口" />
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{windows.length ? windows.map((item) => (
|
||||
<div key={item.window_days} className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="text-sm font-semibold text-text-primary">T+{item.window_days}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<StatCell label="样本" value={item.count} />
|
||||
<StatCell label="胜率" value={`${item.win_rate.toFixed(1)}%`} />
|
||||
<StatCell label="收益" value={`${item.avg_return > 0 ? "+" : ""}${item.avg_return.toFixed(2)}%`} />
|
||||
<StatCell label="目标率" value={`${item.hit_target_rate.toFixed(1)}%`} />
|
||||
<StatCell label="止损率" value={`${item.hit_stop_rate.toFixed(1)}%`} />
|
||||
<StatCell label="回撤" value={`${item.avg_max_drawdown.toFixed(1)}%`} />
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-sm text-text-muted">暂无窗口样本。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPatchPromptPanel({
|
||||
prompts,
|
||||
copiedTitle,
|
||||
onCopy,
|
||||
}: {
|
||||
prompts: AgentPatchPrompt[];
|
||||
copiedTitle: string;
|
||||
onCopy: (item: AgentPatchPrompt) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title="Agent 修改提示词" />
|
||||
<span className="text-xs text-text-muted">大幅策略改造先人工审查</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{prompts.length ? prompts.map((item) => (
|
||||
<div key={item.title} className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary">{item.title}</div>
|
||||
<div className="mt-1 text-xs text-text-muted">{item.evidence}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onCopy(item)}
|
||||
className="shrink-0 rounded-xl border border-cyan-500/15 bg-cyan-500/[0.05] px-3 py-2 text-xs font-semibold text-cyan-400 transition-colors hover:bg-cyan-500/10"
|
||||
>
|
||||
{copiedTitle === item.title ? "已复制" : "复制提示词"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl bg-surface-2/70 p-3 text-xs leading-6 text-text-secondary">
|
||||
{item.prompt}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.target_files.map((file) => (
|
||||
<span key={file} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-sm text-text-muted">
|
||||
当前样本不足或没有集中失效模式,暂不生成代码级改造提示词。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUnknown(value: unknown): string {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (value == null) return "空";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "复杂配置";
|
||||
}
|
||||
}
|
||||
|
||||
function UsageCard({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
|
||||
@ -136,8 +136,8 @@ export interface RecommendationData {
|
||||
stop_loss: number | null;
|
||||
reasons: string[];
|
||||
risk_note: string;
|
||||
strategy?: "momentum" | "potential" | "trend_breakout";
|
||||
entry_signal_type?: "breakout" | "pullback" | "launch" | "none";
|
||||
strategy?: "momentum" | "potential" | "trend_breakout" | "breakout_attack" | "pullback_rotation" | "launch_probe" | "defensive_watch";
|
||||
entry_signal_type?: "breakout" | "breakout_confirm" | "pullback" | "launch" | "reversal" | "flow_momentum" | "none";
|
||||
llm_analysis?: string;
|
||||
llm_score?: number | null;
|
||||
recall_tags?: string[];
|
||||
@ -340,6 +340,93 @@ export interface StrategyAdjustment {
|
||||
confidence: string;
|
||||
}
|
||||
|
||||
export interface StrategyReviewWindow {
|
||||
window_days: number;
|
||||
count: number;
|
||||
win_rate: number;
|
||||
avg_return: number;
|
||||
hit_target_rate: number;
|
||||
hit_stop_rate: number;
|
||||
avg_max_return: number;
|
||||
avg_max_drawdown: number;
|
||||
}
|
||||
|
||||
export interface StrategyReviewCase {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
sector: string;
|
||||
strategy: string;
|
||||
entry_signal_type: string;
|
||||
action_plan: string;
|
||||
score: number;
|
||||
market_temp_score: number;
|
||||
sector_score: number;
|
||||
capital_score: number;
|
||||
position_score: number;
|
||||
pct_from_entry: number;
|
||||
max_return_pct: number;
|
||||
max_drawdown_pct: number;
|
||||
days_since_recommendation: number;
|
||||
close_reason: string;
|
||||
review_note: string;
|
||||
recall_tags: string[];
|
||||
}
|
||||
|
||||
export interface AgentPatchPrompt {
|
||||
title: string;
|
||||
severity: "high" | "medium" | "low" | string;
|
||||
evidence: string;
|
||||
target_files: string[];
|
||||
prompt: string;
|
||||
acceptance_criteria: string[];
|
||||
}
|
||||
|
||||
export interface StrategyConfigRecord {
|
||||
id: number;
|
||||
strategy_id: string;
|
||||
version: number;
|
||||
config: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
source: string;
|
||||
change_reason: string;
|
||||
evidence: Record<string, unknown>;
|
||||
effective_from: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PromptConfigRecord {
|
||||
id: number;
|
||||
prompt_key: string;
|
||||
version: number;
|
||||
content: string;
|
||||
is_active: boolean;
|
||||
source: string;
|
||||
change_reason: string;
|
||||
evidence: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StrategyConfigChange {
|
||||
id: number;
|
||||
change_type: string;
|
||||
status: string;
|
||||
strategy_id: string;
|
||||
prompt_key: string;
|
||||
base_version: number;
|
||||
new_version: number;
|
||||
diff: Record<string, { from: unknown; to: unknown }>;
|
||||
evidence: Record<string, unknown>;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
applied_at: string;
|
||||
}
|
||||
|
||||
export interface StrategyConfigCenter {
|
||||
strategies: StrategyConfigRecord[];
|
||||
prompts: PromptConfigRecord[];
|
||||
changes: StrategyConfigChange[];
|
||||
}
|
||||
|
||||
export interface StrategyIterationReport {
|
||||
generated_at: string;
|
||||
sample_size: number;
|
||||
@ -347,7 +434,12 @@ export interface StrategyIterationReport {
|
||||
strategy_stats: StrategyStat[];
|
||||
signal_stats: StrategyStat[];
|
||||
failure_patterns: string[];
|
||||
review_windows?: StrategyReviewWindow[];
|
||||
failure_cases?: StrategyReviewCase[];
|
||||
success_patterns?: StrategyReviewCase[];
|
||||
adjustment_suggestions: StrategyAdjustment[];
|
||||
agent_patch_prompts?: AgentPatchPrompt[];
|
||||
auto_config_change?: StrategyConfigRecord | null;
|
||||
ai_analysis: string;
|
||||
generated_by: string;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user