Files
MoFin/venv/lib/python3.12/site-packages/alphasift/cli.py
T
知微 fa45d8aa5f fix: 小果地址统一node122(兼容LAN+EasyTier)
- health_checklist.json: 192.168.1.122→node122
- ocr_client.py: docstring IP→node122
- docs/market-data-requirements.md: IP→node122
- 所有API调用通过ProxyHandler({})绕过系统代理
  Privoxy对node122:18003返回500,直连正常
2026-06-30 02:56:35 +08:00

1169 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""CLI entry point."""
import argparse
import json
import logging
import os
import sys
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from alphasift.audit import audit_project
from alphasift.config import Config
from alphasift.doctor import doctor_data_sources, write_doctor_report
from alphasift.dsa import check_dsa_readiness
from alphasift.evaluate import evaluate_saved_run, evaluate_saved_runs, evaluate_saved_runs_by_windows
from alphasift.hotspot import (
append_hotspot_history,
discover_hotspots,
get_hotspot_detail,
hotspot_detail_to_dict,
save_hotspots_json,
)
from alphasift.industry import fetch_akshare_board_map, save_industry_map
from alphasift.pipeline import screen
from alphasift.store import (
evaluation_result_to_jsonl,
list_saved_runs,
save_evaluation_result,
save_screen_result,
screen_result_to_jsonl,
)
from alphasift.strategy import list_strategies
def main():
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(prog="alphasift", description="自动选股 Skill")
parser.add_argument(
"--env-file",
action="append",
default=None,
help="加载额外 .env 文件,可重复;用于复用 daily_stock_analysis/daily_ai_assistant 配置",
)
sub = parser.add_subparsers(dest="command")
# screen
sp = sub.add_parser("screen", help="执行选股")
sp.add_argument("strategy", help="策略名称")
sp.add_argument("--market", default="cn")
sp.add_argument("--max-output", type=int, default=None)
sp.add_argument("--no-llm", action="store_true", help="不使用 LLM 排序")
sp.add_argument(
"--context",
default=None,
help="传给 LLM 的市场/新闻/主题上下文,不参与硬筛,只用于候选相对排序",
)
sp.add_argument(
"--context-file",
action="append",
default=None,
help="追加传给 LLM 的上下文文本文件,可重复",
)
sp.add_argument(
"--candidate-context-file",
action="append",
default=None,
help="追加候选级上下文 CSV/JSON/JSONL,需包含 code,可含 news/announcement/fund_flow/text 等列",
)
sp.add_argument(
"--collect-candidate-context",
action="store_true",
help="对送入 LLM 的 Top K 候选抓取新闻/公告/资金流线索,默认关闭",
)
sp.add_argument(
"--candidate-context-max-candidates",
type=int,
default=None,
help="最多对前 N 个 LLM 候选抓取外部线索",
)
sp.add_argument(
"--candidate-context-provider",
action="append",
default=None,
help="候选级抓取来源:news、announcement、fund_flow;可重复或逗号分隔",
)
sp.add_argument(
"--industry-map-file",
action="append",
default=None,
help="追加 code->industry/concepts 映射 CSV/JSON/JSONL,可重复",
)
sp.add_argument(
"--industry-provider",
default=None,
help="可选行业/概念映射 provider,例如 akshare;默认读取 INDUSTRY_PROVIDER",
)
sp.add_argument(
"--post-analyzer",
action="append",
default=None,
help="追加 L3 后置分析器:scorecard、dsa、external_http;可重复或逗号分隔",
)
sp.add_argument(
"--no-post-analysis",
action="store_true",
help="关闭默认 L3 后置评分器和其他后置分析器",
)
sp.add_argument(
"--post-analysis-max-picks",
type=int,
default=None,
help="最多对前 N 只候选运行 L3 后置分析器",
)
sp.add_argument("--deep-analysis", action="store_true", help="兼容参数:等同启用 --post-analyzer dsa")
sp.add_argument(
"--deep-analysis-max-picks",
type=int,
default=None,
help="最多对前 N 只候选调用 DSA(默认使用环境变量或 3)",
)
sp.add_argument(
"--daily-enrich",
dest="daily_enrich",
action="store_true",
default=None,
help="对 L1 后的 Top N 候选补充日 K 特征",
)
sp.add_argument(
"--no-daily-enrich",
dest="daily_enrich",
action="store_false",
help="即使环境变量开启也不做可选日 K 增强;策略必需的日 K 过滤仍会执行",
)
sp.add_argument(
"--daily-enrich-max-candidates",
type=int,
default=None,
help="日 K 增强最多处理的候选数",
)
sp.add_argument("--explain-filters", action="store_true", help="输出 hard filter waterfall 诊断")
sp.add_argument("--save-run", action="store_true", help="保存本次运行到 ALPHASIFT_DATA_DIR/runs")
sp.add_argument("--output", default=None, help="额外写出结果到指定路径")
sp.add_argument("--jsonl", action="store_true", help="以 JSONL 输出")
sp.add_argument("--explain", action="store_true", help="输出紧凑可读摘要")
# strategies
sub.add_parser("strategies", help="列出可用策略")
# evaluate
ep = sub.add_parser("evaluate", help="用最新快照评估已保存的选股结果")
ep.add_argument("run", help="run_id 或保存的 run JSON 文件路径")
ep.add_argument("--save", action="store_true", help="保存评估结果到 ALPHASIFT_DATA_DIR/evaluations")
ep.add_argument("--output", default=None, help="额外写出评估结果到指定路径")
ep.add_argument("--jsonl", action="store_true", help="以 JSONL 输出")
ep.add_argument("--explain", action="store_true", help="输出紧凑可读摘要")
ep.add_argument("--cost-bps", type=float, default=None, help="评估收益扣除的往返成本,单位 bps")
ep.add_argument("--follow-through-pct", type=float, default=None, help="突破延续判定的最低收益百分比")
ep.add_argument("--failed-breakout-pct", type=float, default=None, help="突破失败判定的最高收益百分比")
ep.add_argument("--with-price-path", action="store_true", help="额外抓取日 K 路径,计算最大回撤和最大浮盈")
ep.add_argument("--price-path-lookback-days", type=int, default=None, help="价格路径日 K 回看天数")
# evaluate-batch
ebp = sub.add_parser("evaluate-batch", help="批量评估最近保存的选股结果并按策略聚合")
ebp.add_argument("--limit", type=int, default=20, help="最多评估最近 N 个 run")
ebp.add_argument("--strategy", default=None, help="只评估指定策略")
ebp.add_argument("--output", default=None, help="额外写出批量评估 JSON 到指定路径")
ebp.add_argument("--json", action="store_true", help="以 JSON 输出")
ebp.add_argument("--explain", action="store_true", help="输出紧凑可读摘要")
ebp.add_argument("--cost-bps", type=float, default=None, help="评估收益扣除的往返成本,单位 bps")
ebp.add_argument("--follow-through-pct", type=float, default=None, help="突破延续判定的最低收益百分比")
ebp.add_argument("--failed-breakout-pct", type=float, default=None, help="突破失败判定的最高收益百分比")
ebp.add_argument("--with-price-path", action="store_true", help="额外抓取日 K 路径,计算最大回撤和最大浮盈")
ebp.add_argument("--price-path-lookback-days", type=int, default=None, help="价格路径日 K 回看天数")
# evaluate-strategies
esp = sub.add_parser("evaluate-strategies", help="生成策略级评估 summary")
esp.add_argument("--limit", type=int, default=20, help="最多评估最近 N 个 run")
esp.add_argument("--strategy", default=None, help="只评估指定策略")
esp.add_argument("--output", default=None, help="额外写出策略评估 JSON 到指定路径")
esp.add_argument("--json", action="store_true", help="以 JSON 输出")
esp.add_argument("--explain", action="store_true", help="输出紧凑可读摘要")
esp.add_argument("--cost-bps", type=float, default=None, help="评估收益扣除的往返成本,单位 bps")
esp.add_argument("--follow-through-pct", type=float, default=None, help="突破延续判定的最低收益百分比")
esp.add_argument("--failed-breakout-pct", type=float, default=None, help="突破失败判定的最高收益百分比")
esp.add_argument("--with-price-path", action="store_true", help="额外抓取日 K 路径,计算最大回撤和最大浮盈")
esp.add_argument("--price-path-lookback-days", type=int, default=None, help="价格路径日 K 回看天数")
esp.add_argument(
"--window",
default=None,
help="用逗号分隔多个窗口对价格路径进行滚动回看,例如 5,10,20;和 --price-path-lookback-days 互斥",
)
# runs
rp = sub.add_parser("runs", help="列出已保存的运行")
rp.add_argument("--limit", type=int, default=20)
# industry-cache
icp = sub.add_parser("industry-cache", help="刷新行业/概念映射缓存文件")
icp.add_argument("--provider", default="akshare", choices=["akshare"], help="行业/概念 provider")
icp.add_argument("--max-boards", type=int, default=80, help="最多抓取行业和概念板块数")
icp.add_argument("--output", default="data/industry_map.csv", help="输出 CSV/JSON 路径")
icp.add_argument("--explain", action="store_true", help="输出紧凑摘要")
# hotspots
hp = sub.add_parser("hotspots", help="发现并排序热点概念/行业")
hp.add_argument("--top", type=int, default=20, help="输出前 N 个热点")
hp.add_argument("--provider", default="akshare", help="热点 providerakshare 或 none")
hp.add_argument("--max-boards", type=int, default=80, help="每类最多读取 N 个板块")
hp.add_argument("--history-path", default=None, help="读取热点 history JSONL 并回填趋势")
hp.add_argument("--fallback-cache", default=None, help="live provider 失败/无数据时读取 last-good 热点缓存")
hp.add_argument("--output", default=None, help="额外写出热点 JSON")
hp.add_argument("--explain", action="store_true", help="输出紧凑摘要")
hdp = sub.add_parser("hotspot", help="查看单个热点详情")
hdp.add_argument("topic", help="热点/概念/行业名称")
hdp.add_argument("--provider", default="akshare", help="热点 providerakshare 或 none")
hdp.add_argument("--top-stocks", type=int, default=10, help="输出前 N 只热点成分股")
hdp.add_argument("--history-path", default=None, help="读取热点 history JSONL 并回填趋势")
hdp.add_argument("--fallback-cache", default=None, help="live provider 失败/无数据时读取 last-good 热点缓存")
hdp.add_argument("--timeline", action="store_true", help="加载并展示热点时间线")
hdp.add_argument("--timeline-path", default=None, help="热点时间线 JSONL")
hdp.add_argument("--output", default=None, help="额外写出详情 JSON")
hdp.add_argument("--explain", action="store_true", help="输出紧凑摘要")
hcp = sub.add_parser("hotspot-cache", help="刷新热点排行缓存并追加 history")
hcp.add_argument("--top", type=int, default=20, help="缓存前 N 个热点")
hcp.add_argument("--provider", default="akshare", help="热点 providerakshare 或 none")
hcp.add_argument("--max-boards", type=int, default=80, help="每类最多读取 N 个板块")
hcp.add_argument("--history-path", default="data/hotspot.history.jsonl", help="追加写入 history JSONL")
hcp.add_argument("--output", default="data/hotspots.json", help="输出热点 JSON")
hcp.add_argument("--explain", action="store_true", help="输出紧凑摘要")
# audit
ap = sub.add_parser("audit", help="评估项目能力、策略配置覆盖和已知短板")
ap.add_argument("--json", action="store_true", help="以 JSON 输出")
# doctor
dp = sub.add_parser("doctor", help="诊断运行环境和数据源")
doctor_sub = dp.add_subparsers(dest="doctor_command")
dsp = doctor_sub.add_parser("data-sources", help="诊断 snapshot / daily 数据源状态")
dsp.add_argument("--snapshot-source", action="append", default=None, help="snapshot 来源,可重复或逗号分隔")
dsp.add_argument("--daily-source", default=None, help="daily K 来源,默认使用 DAILY_SOURCE/config")
dsp.add_argument("--daily-code", default="000001", help="daily K smoke test 股票代码,默认 000001")
dsp.add_argument("--no-live", action="store_true", help="只输出配置和内存 health,不发起网络取数")
dsp.add_argument("--no-daily", action="store_true", help="跳过 daily K smoke test")
dsp.add_argument("--output", default=None, help="额外写出 JSON 诊断报告")
dsp.add_argument("--json", action="store_true", help="以 JSON 输出")
dsp.add_argument("--explain", action="store_true", help="输出紧凑可读摘要")
drp = doctor_sub.add_parser("dsa-readiness", help="诊断可选 DSA 分析服务可用性")
drp.add_argument("--api-url", default=None, help="DSA base URL 或 analyze endpoint;默认读取 DSA_API_URL")
drp.add_argument("--timeout-sec", type=float, default=5.0, help="readiness probe 超时秒数")
drp.add_argument("--output", default=None, help="额外写出 JSON 诊断报告")
drp.add_argument("--json", action="store_true", help="以 JSON 输出")
drp.add_argument("--explain", action="store_true", help="输出紧凑可读摘要")
# quickstart
qp = sub.add_parser(
"quickstart",
help="一键演示:列出策略 → 跑一个无 LLM 的 dual_low → 输出排名摘要",
)
qp.add_argument("--strategy", default="dual_low", help="演示用策略,默认 dual_low")
qp.add_argument("--max-output", type=int, default=5, help="演示输出候选数")
args = parser.parse_args()
_apply_env_file_args(args.env_file)
if args.command == "screen":
config = Config.from_env()
if args.no_post_analysis and (args.post_analyzer or args.deep_analysis):
parser.error("--no-post-analysis cannot be combined with --post-analyzer or --deep-analysis")
post_analyzers = []
if not args.no_post_analysis:
post_analyzers = list(config.post_analyzers)
if args.post_analyzer:
post_analyzers.extend(args.post_analyzer)
result = screen(
args.strategy,
market=args.market,
max_output=args.max_output,
use_llm=not args.no_llm,
llm_context=args.context,
llm_context_files=args.context_file,
candidate_context_files=args.candidate_context_file,
collect_llm_candidate_context=args.collect_candidate_context or None,
candidate_context_max_candidates=args.candidate_context_max_candidates,
candidate_context_providers=_split_csv_args(args.candidate_context_provider),
industry_map_files=args.industry_map_file,
industry_provider=args.industry_provider,
post_analyzers=post_analyzers,
post_analysis_max_picks=args.post_analysis_max_picks,
daily_enrich=args.daily_enrich,
daily_enrich_max_candidates=args.daily_enrich_max_candidates,
explain_filters=args.explain_filters,
deep_analysis=args.deep_analysis,
deep_analysis_max_picks=args.deep_analysis_max_picks,
config=config,
)
if args.save_run:
save_screen_result(result, data_dir=config.data_dir)
if args.output:
save_screen_result(result, data_dir=config.data_dir, path=args.output, jsonl=args.jsonl)
if args.explain:
print(_format_screen_explain(result))
elif args.jsonl:
print("\n".join(screen_result_to_jsonl(result)))
else:
print(json.dumps(asdict(result), ensure_ascii=False, indent=2))
elif args.command == "strategies":
for s in list_strategies():
tags = ",".join(s.tags)
suffix = f" tags={tags}" if tags else ""
print(
f" {s.name:<25} {s.display_name:<10} "
f"v{s.version:<5} [{s.category}] {s.description}{suffix}"
)
elif args.command == "evaluate":
config = Config.from_env()
result = evaluate_saved_run(
args.run,
config=config,
cost_bps=args.cost_bps,
follow_through_pct=args.follow_through_pct,
failed_breakout_pct=args.failed_breakout_pct,
with_price_path=args.with_price_path or None,
price_path_lookback_days=args.price_path_lookback_days,
)
if args.save:
save_evaluation_result(result, data_dir=config.data_dir)
if args.output:
save_evaluation_result(result, data_dir=config.data_dir, path=args.output, jsonl=args.jsonl)
if args.explain:
print(_format_evaluation_explain(result))
elif args.jsonl:
print("\n".join(evaluation_result_to_jsonl(result)))
else:
print(json.dumps(asdict(result), ensure_ascii=False, indent=2))
elif args.command == "evaluate-batch":
config = Config.from_env()
result = evaluate_saved_runs(
config=config,
limit=args.limit,
strategy=args.strategy,
cost_bps=args.cost_bps,
follow_through_pct=args.follow_through_pct,
failed_breakout_pct=args.failed_breakout_pct,
with_price_path=args.with_price_path or None,
price_path_lookback_days=args.price_path_lookback_days,
)
if args.output:
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
Path(args.output).write_text(
json.dumps(result, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if args.explain:
print(_format_evaluation_batch_explain(result))
else:
print(json.dumps(result, ensure_ascii=False, indent=2))
elif args.command == "evaluate-strategies":
config = Config.from_env()
try:
windows = _parse_window_list(args.window)
except ValueError as exc:
parser.error(str(exc))
if windows and args.price_path_lookback_days is not None:
parser.error("--window is incompatible with --price-path-lookback-days for this command")
if windows:
result = evaluate_saved_runs_by_windows(
windows=windows,
config=config,
limit=args.limit,
strategy=args.strategy,
cost_bps=args.cost_bps,
follow_through_pct=args.follow_through_pct,
failed_breakout_pct=args.failed_breakout_pct,
)
else:
result = evaluate_saved_runs(
config=config,
limit=args.limit,
strategy=args.strategy,
cost_bps=args.cost_bps,
follow_through_pct=args.follow_through_pct,
failed_breakout_pct=args.failed_breakout_pct,
with_price_path=args.with_price_path or None,
price_path_lookback_days=args.price_path_lookback_days,
)
payload = {
"evaluated_at": result.get("evaluated_at"),
"snapshot_source": result.get("snapshot_source"),
"source_errors": result.get("source_errors", []),
"limit": result.get("limit"),
"strategy_filter": result.get("strategy_filter", ""),
"cost_bps": result.get("cost_bps"),
"with_price_path": result.get("with_price_path"),
"price_path_window_days": result.get("price_path_window_days", []),
"strategy_summaries": result.get("strategy_summaries", []),
}
if args.output:
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
Path(args.output).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if args.explain or not args.json:
print(_format_evaluate_strategies_explain(payload))
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
elif args.command == "runs":
config = Config.from_env()
for item in list_saved_runs(data_dir=config.data_dir, limit=args.limit):
print(
f"{item['run_id']:<14} {item['strategy']:<20} "
f"{item['created_at']:<26} picks={item['picks']} {item['path']}"
)
elif args.command == "industry-cache":
mapping, notes = fetch_akshare_board_map(max_boards=args.max_boards)
output_path = save_industry_map(mapping, args.output)
generated_at = datetime.now().isoformat()
history_path = _append_industry_cache_history(
output_path,
mapping=mapping,
generated_at=generated_at,
)
metadata_path = _write_industry_cache_metadata(
output_path,
provider=args.provider,
max_boards=args.max_boards,
rows=len(mapping),
notes=notes,
generated_at=generated_at,
history_path=history_path,
)
if args.explain:
print(
f"industry_cache={output_path} metadata={metadata_path} "
f"history={history_path} rows={len(mapping)} "
f"notes={' | '.join(notes)}"
)
else:
print(json.dumps({
"path": str(output_path),
"metadata_path": str(metadata_path),
"history_path": str(history_path),
"rows": len(mapping),
"notes": notes,
}, ensure_ascii=False, indent=2))
elif args.command == "hotspots":
hotspots = discover_hotspots(
provider=args.provider,
max_boards=args.max_boards,
history_path=args.history_path,
fallback_cache_path=args.fallback_cache,
top=args.top,
)
if args.output:
save_hotspots_json(args.output, hotspots)
if args.explain:
print(_format_hotspots_explain(hotspots, provider=args.provider))
else:
print(json.dumps([asdict(item) for item in hotspots], ensure_ascii=False, indent=2))
elif args.command == "hotspot":
timeline_path = args.timeline_path if args.timeline else None
detail = get_hotspot_detail(
args.topic,
provider=args.provider,
top_stocks=args.top_stocks,
timeline_path=timeline_path,
history_path=args.history_path,
fallback_cache_path=args.fallback_cache,
)
if args.output:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps(hotspot_detail_to_dict(detail), ensure_ascii=False, indent=2),
encoding="utf-8",
)
if args.explain:
print(_format_hotspot_detail_explain(detail))
else:
print(json.dumps(hotspot_detail_to_dict(detail), ensure_ascii=False, indent=2))
elif args.command == "hotspot-cache":
hotspots = discover_hotspots(
provider=args.provider,
max_boards=args.max_boards,
history_path=args.history_path,
fallback_cache_path=args.output,
top=args.top,
)
fallback_used = bool(getattr(hotspots, "fallback_used", False))
generated_at = datetime.now().isoformat()
history_path = None
history_appended = False
output_path = Path(args.output)
if fallback_used:
if not output_path.exists():
output_path = save_hotspots_json(args.output, hotspots)
else:
output_path = save_hotspots_json(args.output, hotspots)
history_path = append_hotspot_history(args.history_path, hotspots, generated_at=generated_at)
history_appended = True
metadata_path = _write_hotspot_cache_metadata(
output_path,
provider=args.provider,
max_boards=args.max_boards,
rows=len(hotspots),
generated_at=generated_at,
history_path=history_path,
fallback_used=fallback_used,
source_errors=getattr(hotspots, "source_errors", []),
history_appended=history_appended,
)
if args.explain:
print(
f"hotspot_cache={output_path} metadata={metadata_path} "
f"history={history_path or '-'} rows={len(hotspots)} "
f"fallback={fallback_used}"
)
else:
print(json.dumps({
"path": str(output_path),
"metadata_path": str(metadata_path),
"history_path": str(history_path) if history_path is not None else "",
"schema_version": 2,
"rows": len(hotspots),
"fallback_used": fallback_used,
"source_errors": getattr(hotspots, "source_errors", []),
}, ensure_ascii=False, indent=2))
elif args.command == "audit":
config = Config.from_env()
result = audit_project(config.strategies_dir)
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(_format_audit_explain(result))
elif args.command == "doctor":
config = Config.from_env()
if args.doctor_command == "data-sources":
result = doctor_data_sources(
config,
snapshot_sources=_split_csv_args(args.snapshot_source) or None,
daily_source=args.daily_source,
daily_code=args.daily_code,
run_live=not args.no_live,
check_daily=not args.no_daily,
)
if args.output:
write_doctor_report(args.output, result)
if args.json or not args.explain:
print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
else:
print(_format_data_sources_doctor_explain(result.to_dict()))
elif args.doctor_command == "dsa-readiness":
result = check_dsa_readiness(
args.api_url if args.api_url is not None else config.dsa_api_url,
timeout_sec=args.timeout_sec,
)
if args.output:
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
if args.json or not args.explain:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(_format_dsa_readiness_explain(result))
else:
parser.error("doctor requires a subcommand, e.g. doctor data-sources")
elif args.command == "quickstart":
_run_quickstart(strategy=args.strategy, max_output=args.max_output)
else:
parser.print_help()
sys.exit(1)
def _run_quickstart(*, strategy: str = "dual_low", max_output: int = 5) -> None:
"""One-shot showcase: list strategies, screen without LLM, print top picks.
Mirrors the AlphaEvo `showcase` UX: no API key required, prints a
deterministic-looking summary that fits a single screen.
"""
print("=" * 60)
print("AlphaSift Quickstart · 无 API key 演示")
print("=" * 60)
print()
config = Config.from_env()
strategies = list_strategies(config.strategies_dir)
print(f"[1/3] 可用策略 ({len(strategies)}):")
for s in strategies:
marker = "" if s.name == strategy else " "
print(f" {marker} {s.name:<20s} {s.display_name}")
print()
print(f"[2/3] 执行 `{strategy}` 选股 (--no-llm, --no-post-analysis, top {max_output}) …")
try:
result = screen(
strategy,
market="cn",
max_output=max_output,
use_llm=False,
post_analyzers=[],
)
except Exception as exc: # noqa: BLE001
print(f" 失败: {exc}")
print(" 提示: 检查网络,或设置 SNAPSHOT_SOURCE_PRIORITY / TUSHARE_TOKEN")
sys.exit(2)
print(
f" 全市场 {result.snapshot_count} 只 → 硬筛后 {result.after_filter_count}"
f"→ 输出 {len(result.picks)} 只 (源: {result.snapshot_source})"
)
print()
print("[3/3] 候选排名:")
print(f" {'rank':<5}{'code':<10}{'name':<14}{'score':<8}{'price':<8}{'pe':<8}{'pb':<6}")
for pick in result.picks:
pe = f"{pick.pe_ratio:.1f}" if pick.pe_ratio is not None else "-"
pb = f"{pick.pb_ratio:.2f}" if pick.pb_ratio is not None else "-"
print(
f" {pick.rank:<5}{pick.code:<10}{pick.name[:12]:<14}"
f"{pick.final_score:<8.1f}{pick.price:<8.2f}{pe:<8}{pb:<6}"
)
print()
print("下一步:")
print(" alphasift screen <strategy> --explain # 查看入选理由和因子分")
print(" alphasift screen <strategy> --save-run # 保存运行")
print(" alphasift evaluate <run_id> --explain # T+N 评估")
print(" alphasift strategies # 完整策略列表")
def _format_screen_explain(result) -> str:
lines = [
f"run_id={result.run_id} strategy={result.strategy} market={result.market}",
(
f"snapshot={result.snapshot_count} after_filter={result.after_filter_count} "
f"source={result.snapshot_source or '-'} llm_ranked={result.llm_ranked}"
),
]
if result.post_analyzers:
lines.append(f"post_analyzers={','.join(result.post_analyzers)}")
if result.llm_market_view:
lines.append(f"llm_market_view={result.llm_market_view}")
if result.llm_selection_logic:
lines.append(f"llm_selection_logic={result.llm_selection_logic}")
if result.llm_portfolio_risk:
lines.append(f"llm_portfolio_risk={result.llm_portfolio_risk}")
if result.portfolio_concentration_notes:
lines.append("portfolio_concentration=" + " | ".join(result.portfolio_concentration_notes))
if result.saved_path:
lines.append(f"saved_path={result.saved_path}")
if result.degradation:
lines.append("degradation=" + " | ".join(result.degradation))
lines.append("rank code name final screen risk sector penalty reason")
for pick in result.picks:
reason = (
pick.llm_thesis
or pick.ranking_reason
or pick.post_analysis_summaries.get("scorecard", "")
)
lines.append(
f"{pick.rank:<4} {pick.code:<8} {pick.name:<10} "
f"{pick.final_score:>6.1f} {pick.screen_score:>6.1f} "
f"{pick.risk_level or '-':<6} {pick.llm_sector or '-':<8} "
f"{pick.portfolio_penalty:>4.1f} {reason[:48]}"
)
return "\n".join(lines)
def _format_evaluation_explain(result) -> str:
lines = [
f"run_id={result.run_id} strategy={result.strategy} elapsed_days={result.elapsed_days}",
(
f"avg_return={result.average_return_pct} "
f"median_return={result.median_return_pct} win_rate={result.win_rate}"
),
]
if result.saved_path:
lines.append(f"saved_path={result.saved_path}")
if result.degradation:
lines.append("degradation=" + " | ".join(result.degradation))
lines.append("rank code name entry current return_pct status shape max_dd max_runup")
for pick in result.picks:
current = "-" if pick.current_price is None else f"{pick.current_price:.2f}"
ret = "-" if pick.return_pct is None else f"{pick.return_pct:.2f}%"
lines.append(
f"{pick.rank:<4} {pick.code:<8} {pick.name:<10} "
f"{pick.entry_price:<8.2f} {current:<8} {ret:<9} {pick.status:<10} "
f"{pick.shape_status or '-':<24} "
f"{_fmt_pct(pick.max_drawdown_pct):<8} {_fmt_pct(pick.max_runup_pct)}"
)
return "\n".join(lines)
def _format_evaluation_batch_explain(result: dict) -> str:
summary = result.get("summary", {})
lines = [
(
f"evaluated_at={result.get('evaluated_at')} "
f"source={result.get('snapshot_source') or '-'} "
f"runs={summary.get('run_count')} picks={summary.get('pick_count')} "
f"cost_bps={result.get('cost_bps')} "
f"follow_through={result.get('follow_through_pct')} "
f"failed_breakout={result.get('failed_breakout_pct')} "
f"price_path={result.get('with_price_path')}"
),
(
f"avg_return={summary.get('average_return_pct')} "
f"median_return={summary.get('median_return_pct')} "
f"win_rate={summary.get('win_rate')} "
f"missing={summary.get('missing_count')} "
f"path_picks={summary.get('path_pick_count')} "
f"avg_max_dd={summary.get('average_max_drawdown_pct')} "
f"avg_max_runup={summary.get('average_max_runup_pct')}"
),
]
if result.get("source_errors"):
lines.append("source_errors=" + " | ".join(result["source_errors"]))
if result.get("by_strategy"):
lines.append("strategy run_count pick_count avg_return median_return win_rate missing")
for strategy, item in sorted(result["by_strategy"].items()):
lines.append(
f"{strategy:<20} {item.get('run_count'):<9} {item.get('pick_count'):<10} "
f"{item.get('average_return_pct')!s:<10} {item.get('median_return_pct')!s:<13} "
f"{item.get('win_rate')!s:<8} {item.get('missing_count')}"
)
dimensions = result.get("dimensions", {})
for title, key in (
("top_sectors", "by_sector"),
("top_themes", "by_theme"),
("top_risk_flags", "by_risk_flag"),
("shape_status", "by_shape_status"),
("shape_tags", "by_shape_tag"),
("path_status", "by_path_status"),
("holding_periods", "by_holding_period"),
):
items = _top_dimension_items(dimensions.get(key, {}))
if items:
lines.append(f"{title}=" + " | ".join(items))
return "\n".join(lines)
def _format_evaluate_strategies_explain(result: dict) -> str:
lines = [
(
f"evaluated_at={result.get('evaluated_at')} "
f"source={result.get('snapshot_source') or '-'} "
f"limit={result.get('limit')} strategy_filter={result.get('strategy_filter') or '-'} "
f"price_path={result.get('with_price_path')}"
),
]
if result.get("price_path_window_days"):
windows = ",".join(f"{item}d" for item in result.get("price_path_window_days", []))
lines.append(f"windows={windows}")
if result.get("strategy_summaries", []) and isinstance(result.get("strategy_summaries"), list):
sample = result["strategy_summaries"][0]
if isinstance(sample, dict) and sample.get("window_summaries") is not None:
lines.append("strategy windows avg_return median_return win_rate max_dd max_runup failed_follow missing")
else:
lines.append(
"strategy runs picks avg_return median_return win_rate max_dd max_runup outcome shapes"
)
if result.get("source_errors"):
lines.append("source_errors=" + " | ".join(result["source_errors"]))
for item in result.get("strategy_summaries", []):
windows = item.get("window_summaries")
if windows:
line_parts = [
f"{item.get('strategy'):<20} "
f"{item.get('window_count', len(windows))!s:>3}w "
]
for window in windows:
window_days = window.get("window_days", "-")
line_parts.append(
(
f"{window_days}d:"
f"ret={window.get('average_return_pct')!s} "
f"win={window.get('win_rate')!s} "
f"dd={window.get('average_max_drawdown_pct')!s} "
f"runup={window.get('average_max_runup_pct')!s} "
f"f={window.get('failed_breakout_count', 0)} "
f"t={window.get('breakout_follow_through_count', 0)} "
f"m={window.get('missing_count', 0)}; "
)
)
lines.append("".join(line_parts).strip())
else:
shapes = item.get("shape_status_counts", {}) or {}
shape_text = ",".join(f"{name}:{count}" for name, count in sorted(shapes.items())) or "-"
lines.append(
f"{item.get('strategy'):<20} {item.get('run_count'):<4} {item.get('pick_count'):<5} "
f"{item.get('average_return_pct')!s:<10} {item.get('median_return_pct')!s:<13} "
f"{item.get('win_rate')!s:<8} {item.get('average_max_drawdown_pct')!s:<8} "
f"{item.get('average_max_runup_pct')!s:<9} {item.get('outcome'):<17} {shape_text}"
)
return "\n".join(lines)
def _format_hotspots_explain(hotspots: list, *, provider: str = "") -> str:
provider_text = getattr(hotspots, "provider_used", "") or provider or "-"
metadata_bits = [f"hotspots={len(hotspots)}", f"provider={provider_text}", "schema_version=2"]
if getattr(hotspots, "fallback_used", False):
metadata_bits.append("fallback=True")
if getattr(hotspots, "stale", False):
metadata_bits.append("stale=True")
source_errors = getattr(hotspots, "source_errors", []) or []
if source_errors:
metadata_bits.append("source_errors=" + " | ".join(source_errors))
if not hotspots:
return " ".join(metadata_bits)
lines = [
" ".join(metadata_bits),
"rank topic source src_rank change heat trend persistence cooling state stage sample quality leaders",
]
for idx, item in enumerate(hotspots, start=1):
leaders = ",".join(item.leaders[:3]) if getattr(item, "leaders", None) else "-"
lines.append(
f"{idx:<4} {item.topic:<14} {item.source or '-':<8} "
f"{item.rank if item.rank is not None else '-':<8} "
f"{_fmt_pct(item.change_pct):<8} "
f"{item.heat_score:<6.1f} "
f"{_fmt_optional_float(item.trend_score):<8} "
f"{_fmt_optional_float(item.persistence_score):<11} "
f"{_fmt_optional_float(item.cooling_score):<7} "
f"{item.state or '-':<14} {item.stage:<8} "
f"{item.sample_stock_count:<6} {getattr(item, 'quality_status', '-') or '-':<8} {leaders}"
)
return "\n".join(lines)
def _format_hotspot_detail_explain(detail) -> str:
summary = detail.summary
leaders = ",".join(summary.leaders[:3]) if summary.leaders else "-"
lines = [
(
f"topic={summary.topic} source={summary.source or '-'} "
f"canonical={getattr(summary, 'canonical_topic', '') or '-'} "
f"src_rank={summary.rank if summary.rank is not None else '-'} "
f"change={_fmt_pct(summary.change_pct)} heat={summary.heat_score:.1f} "
f"trend={_fmt_optional_float(summary.trend_score)} "
f"persistence={_fmt_optional_float(summary.persistence_score)} "
f"cooling={_fmt_optional_float(summary.cooling_score)} "
f"state={summary.state or '-'} stage={summary.stage} "
f"sample={summary.sample_stock_count} leaders={leaders} "
f"quality={getattr(summary, 'quality_status', '-') or '-'} "
f"provider={summary.provider_used or '-'} fallback={summary.fallback_used}"
),
"rank code name role score change amount turnover volume_ratio net_inflow active evidence",
]
missing_fields = getattr(summary, "missing_fields", []) or []
if missing_fields:
lines.append("missing_fields=" + ",".join(missing_fields))
if summary.source_errors:
lines.append("source_errors=" + " | ".join(summary.source_errors))
for idx, stock in enumerate(detail.stocks, start=1):
lines.append(
f"{idx:<4} {stock.code:<8} {stock.name:<10} {stock.role or '-':<8} "
f"{stock.hot_stock_score:<6.1f} {_fmt_pct(stock.change_pct):<8} "
f"{_fmt_optional_float(stock.amount):<10} "
f"{_fmt_optional_float(stock.turnover_rate):<8} "
f"{_fmt_optional_float(stock.volume_ratio):<12} "
f"{_fmt_optional_float(stock.net_inflow):<10} "
f"{stock.active_days:<6} {stock.evidence_count}"
)
if detail.timeline:
lines.append("timeline date source type impact title codes")
for event in detail.timeline:
codes = ",".join(event.related_codes) if event.related_codes else "-"
lines.append(
f"{event.date:<12} {event.source:<8} {event.event_type:<10} "
f"{event.impact_score:<6.1f} {event.title[:40]} {codes}"
)
if getattr(detail, "route", None):
lines.append("route date source type impact title description")
for item in detail.route:
lines.append(
f"{item.date:<12} {item.source:<8} {item.event_type:<10} "
f"{item.impact_score:<6.1f} {item.title[:32]} {item.description[:80]}"
)
return "\n".join(lines)
def _top_dimension_items(items: dict, *, limit: int = 5) -> list[str]:
ranked = sorted(
items.items(),
key=lambda item: (
item[1].get("pick_count") or 0,
item[1].get("average_return_pct") or -999999,
),
reverse=True,
)
return [
(
f"{label}:n={stats.get('pick_count')},"
f"avg={stats.get('average_return_pct')},win={stats.get('win_rate')}"
)
for label, stats in ranked[:limit]
]
def _fmt_pct(value: float | None) -> str:
return "-" if value is None else f"{float(value):.2f}%"
def _fmt_optional_float(value: float | None) -> str:
return "-" if value is None else f"{float(value):.2f}"
def _format_audit_explain(result: dict) -> str:
profile = result.get("profile_coverage", {})
lines = [
f"project={result.get('project')} positioning={result.get('positioning')}",
f"strategies={result.get('strategy_count')} categories={result.get('categories')}",
"profile_coverage="
+ ", ".join(
f"{name}:{item.get('configured')}/{result.get('strategy_count')}"
for name, item in profile.items()
),
]
lines.append("strengths:")
for item in result.get("strengths", []):
lines.append(f"- [{item.get('area')}] {item.get('message')}")
findings = result.get("strategy_findings", [])
if findings:
lines.append("strategy_findings:")
for item in findings:
lines.append(
f"- [{item.get('severity')}] {item.get('strategy')} "
f"{item.get('area')}: {item.get('message')} "
f"next={item.get('recommendation')}"
)
lines.append("project_gaps:")
for item in result.get("project_gaps", []):
lines.append(
f"- [{item.get('severity')}] {item.get('area')}: "
f"{item.get('message')} next={item.get('recommendation')}"
)
lines.append("next_priorities:")
for item in result.get("next_priorities", []):
lines.append(f"- {item}")
return "\n".join(lines)
def _format_data_sources_doctor_explain(result: dict) -> str:
snapshot = result.get("snapshot", {}) or {}
daily = result.get("daily", {}) or {}
config = result.get("config", {}) or {}
lines = [
f"status={result.get('status')} generated_at={result.get('generated_at')}",
(
"snapshot "
f"status={snapshot.get('status')} source={snapshot.get('source') or '-'} "
f"rows={snapshot.get('rows', 0)} fallback={snapshot.get('fallback_used')} "
f"stale={snapshot.get('stale')} sources={','.join(snapshot.get('sources') or [])}"
),
]
if snapshot.get("errors"):
lines.append("snapshot_errors=" + " | ".join(str(item) for item in snapshot.get("errors") or []))
if daily:
lines.append(
"daily "
f"status={daily.get('status')} source={daily.get('source') or '-'} "
f"rows={daily.get('rows', 0)} stale={daily.get('stale')} "
f"code={config.get('daily_code') or '-'} requested={config.get('daily_source') or '-'}"
)
if daily.get("errors"):
lines.append("daily_errors=" + " | ".join(str(item) for item in daily.get("errors") or []))
lines.append(f"tushare_configured={config.get('tushare_configured')} live_checks={config.get('live_checks')}")
recommendations = result.get("recommendations") or []
if recommendations:
lines.append("recommendations=" + " | ".join(str(item) for item in recommendations))
return "\n".join(lines)
def _format_dsa_readiness_explain(result: dict) -> str:
return "\n".join([
(
f"dsa status={result.get('status')} available={result.get('available')} "
f"endpoint={result.get('endpoint') or '-'} http_status={result.get('http_status')}"
),
f"error={result.get('error') or '-'}",
])
def _write_industry_cache_metadata(
output_path: Path,
*,
provider: str,
max_boards: int,
rows: int,
notes: list[str],
generated_at: str | None = None,
history_path: Path | None = None,
) -> Path:
metadata_path = output_path.with_suffix(output_path.suffix + ".meta.json")
metadata = {
"generated_at": generated_at or datetime.now().isoformat(),
"provider": provider,
"max_boards": max_boards,
"rows": rows,
"history_path": str(history_path) if history_path is not None else "",
"notes": notes,
}
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
return metadata_path
def _write_hotspot_cache_metadata(
output_path: Path,
*,
provider: str,
max_boards: int,
rows: int,
generated_at: str | None = None,
history_path: Path | None = None,
fallback_used: bool = False,
source_errors: list[str] | None = None,
history_appended: bool = True,
) -> Path:
metadata_path = output_path.with_suffix(output_path.suffix + ".meta.json")
metadata = {
"generated_at": generated_at or datetime.now().isoformat(),
"schema_version": 2,
"provider": provider,
"max_boards": max_boards,
"rows": rows,
"history_path": str(history_path) if history_path is not None else "",
"fallback_used": fallback_used,
"source_errors": source_errors or [],
"history_appended": history_appended,
}
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
return metadata_path
def _append_industry_cache_history(
output_path: Path,
*,
mapping: dict[str, dict[str, object]],
generated_at: str,
) -> Path:
history_path = output_path.with_suffix(output_path.suffix + ".history.jsonl")
history_path.parent.mkdir(parents=True, exist_ok=True)
records = _industry_cache_history_records(mapping, generated_at=generated_at)
if records:
with history_path.open("a", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
else:
history_path.touch()
return history_path
def _industry_cache_history_records(
mapping: dict[str, dict[str, object]],
*,
generated_at: str,
) -> list[dict[str, object]]:
grouped: dict[str, dict[str, object]] = {}
for code, item in mapping.items():
summaries = _split_board_heat_summary(item.get("board_heat_summary", ""))
heat_score = _safe_float(item.get("board_heat_score"))
for summary in summaries:
board = summary.split(":", 1)[0].strip()
if not board:
continue
record = grouped.setdefault(summary, {
"generated_at": generated_at,
"board": board,
"summary": summary,
"code_count": 0,
"max_board_heat_score": None,
"sample_codes": [],
})
record["code_count"] = int(record["code_count"]) + 1
current_heat = _safe_float(record.get("max_board_heat_score"))
if heat_score is not None and (current_heat is None or heat_score > current_heat):
record["max_board_heat_score"] = heat_score
sample_codes = record["sample_codes"]
if isinstance(sample_codes, list) and len(sample_codes) < 20:
sample_codes.append(code)
return sorted(grouped.values(), key=lambda item: str(item.get("summary", "")))
def _split_board_heat_summary(value: object) -> list[str]:
summaries = []
for item in str(value or "").split("|"):
summary = item.strip()
if summary and summary.lower() not in {"nan", "none", "<na>"}:
summaries.append(summary)
return summaries
def _safe_float(value: object) -> float | None:
if value is None:
return None
text = str(value).strip().replace("%", "").replace(",", "")
if not text or text.lower() in {"nan", "none", "<na>"}:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _apply_env_file_args(env_files: list[str] | None) -> None:
if not env_files:
return
existing = os.environ.get("ALPHASIFT_ENV_FILES", "")
items = [item for item in existing.split(os.pathsep) if item]
items.extend(env_files)
os.environ["ALPHASIFT_ENV_FILES"] = os.pathsep.join(items)
def _parse_window_list(raw: str | None) -> list[int] | None:
if raw is None:
return None
values: list[int] = []
seen: set[int] = set()
for item in str(raw).split(","):
token = item.strip()
if not token:
continue
try:
value = int(token)
except ValueError as exc:
raise ValueError(f"Invalid --window value: {token}") from exc
if value <= 0:
raise ValueError("--window values must be positive integers")
if value in seen:
continue
seen.add(value)
values.append(value)
return sorted(values)
def _split_csv_args(values: list[str] | None) -> list[str] | None:
if values is None:
return None
result = []
for value in values:
result.extend(item.strip() for item in value.split(",") if item.strip())
return result
if __name__ == "__main__":
main()