|
34 | 34 | from pathlib import Path |
35 | 35 | from typing import Any |
36 | 36 |
|
| 37 | +from .calendar import nearest_trade_date as calendar_nearest_trade_date |
| 38 | +from .health import SourceHealthBook |
| 39 | + |
37 | 40 | # ------------------------------------------------------------------ |
38 | 41 | # 配置 |
39 | 42 | # ------------------------------------------------------------------ |
|
184 | 187 | # 诊断记录 |
185 | 188 | DIAGNOSTICS: list[str] = [] |
186 | 189 | NEWS_URL_VALIDATION_CACHE: dict[str, bool] = {} |
| 190 | +SOURCE_HEALTH = SourceHealthBook() |
187 | 191 |
|
188 | 192 |
|
189 | 193 | def diag(msg: str) -> None: |
@@ -439,13 +443,31 @@ def _fetch_raw(url: str, headers: dict[str, str] | None = None, timeout: int = 1 |
439 | 443 |
|
440 | 444 |
|
441 | 445 | def fetch_json(url: str, headers: dict[str, str] | None = None) -> dict[str, Any]: |
| 446 | + started = time.monotonic() |
442 | 447 | try: |
443 | 448 | raw = _fetch_raw(url, headers) |
444 | 449 | except Exception as e: |
| 450 | + SOURCE_HEALTH.record(_source_from_url(url), ok=False, latency_ms=(time.monotonic() - started) * 1000) |
445 | 451 | return {"_error": str(e)} |
| 452 | + SOURCE_HEALTH.record(_source_from_url(url), ok=True, latency_ms=(time.monotonic() - started) * 1000) |
446 | 453 | return _parse_json_text(raw) |
447 | 454 |
|
448 | 455 |
|
| 456 | +def _source_from_url(url: str) -> str: |
| 457 | + host = urllib.parse.urlparse(url).netloc.lower() |
| 458 | + if "eastmoney" in host: |
| 459 | + return "eastmoney" |
| 460 | + if "sina" in host: |
| 461 | + return "sina" |
| 462 | + if "gtimg" in host or "qq.com" in host: |
| 463 | + return "tencent" |
| 464 | + if "10jqka" in host: |
| 465 | + return "ths" |
| 466 | + if "futunn" in host: |
| 467 | + return "futu" |
| 468 | + return host or "unknown" |
| 469 | + |
| 470 | + |
449 | 471 | def _parse_json_text(raw: str) -> dict[str, Any]: |
450 | 472 | raw = raw.strip() |
451 | 473 | if raw.startswith("(") and raw.endswith(")"): |
@@ -2953,101 +2975,20 @@ def run_stock_news(symbol: str, date_str: str, size: int = 8) -> None: |
2953 | 2975 | print_report_footer() |
2954 | 2976 |
|
2955 | 2977 |
|
2956 | | -def _daily_watchlist_items(watchlist: dict[str, list[str]] | None, key: str) -> list[str]: |
2957 | | - if not watchlist: |
2958 | | - return [] |
2959 | | - values = watchlist.get(key) or [] |
2960 | | - return [str(v).strip() for v in values if str(v).strip()] |
2961 | | - |
2962 | | - |
2963 | | -def _quote_trend_label(qd: QuoteData) -> str: |
2964 | | - pct = qd.change_pct |
2965 | | - if pct is None: |
2966 | | - return "趋势待确认" |
2967 | | - if pct >= 2: |
2968 | | - return "强势上行" |
2969 | | - if pct >= 0.3: |
2970 | | - return "偏强" |
2971 | | - if pct <= -2: |
2972 | | - return "明显走弱" |
2973 | | - if pct <= -0.3: |
2974 | | - return "偏弱" |
2975 | | - return "震荡" |
2976 | | - |
2977 | | - |
2978 | 2978 | def print_daily_watchlist(watchlist: dict[str, list[str]] | None, date_str: str, include_news: bool = True) -> None: |
2979 | | - stocks = _daily_watchlist_items(watchlist, "stocks") |
2980 | | - funds = _daily_watchlist_items(watchlist, "funds") |
2981 | | - print("## 一、关注标的\n") |
2982 | | - if not stocks and not funds: |
2983 | | - print(" 尚未设置关注股票或基金。") |
2984 | | - print() |
2985 | | - return |
| 2979 | + from .reports import print_daily_watchlist as _print_daily_watchlist |
2986 | 2980 |
|
2987 | | - if stocks: |
2988 | | - print("### 个股/ETF行情与趋势\n") |
2989 | | - for symbol in stocks: |
2990 | | - try: |
2991 | | - qd = get_single_stock_quote(symbol, date_str) |
2992 | | - except ValueError as e: |
2993 | | - print(f"- {symbol}: 暂未拿到可核验行情({e})") |
2994 | | - continue |
2995 | | - if not qd: |
2996 | | - print(f"- {symbol}: 暂未拿到可核验行情") |
2997 | | - continue |
2998 | | - name = qd.name or qd.symbol |
2999 | | - print( |
3000 | | - f"- {name} ({qd.symbol}): 最新 {fmt_price(qd.price)} {qd.currency}, " |
3001 | | - f"涨跌幅 {fmt_pct(qd.change_pct)}, {_quote_trend_label(qd)};" |
3002 | | - f"来源 {_source_label(qd.source)},数据日 {qd.date or '-'}。" |
3003 | | - ) |
3004 | | - if include_news: |
3005 | | - keyword = qd.name if qd.market in ("cn_market", "hk_market") and qd.name else qd.symbol |
3006 | | - lang = "zh-CN" if qd.market in ("cn_market", "hk_market") else "en" |
3007 | | - news = combined_news_search(keyword, size=3, lang=lang, aliases=_news_aliases(qd.symbol, qd.name), date_str=date_str) |
3008 | | - items = news.get("data", []) if "_error" not in news else [] |
3009 | | - if items: |
3010 | | - title = _clean_news_title(str(items[0].get("title") or "")) |
3011 | | - print(f" 相关新闻: {title}") |
3012 | | - print() |
3013 | | - |
3014 | | - if funds: |
3015 | | - print("### 基金估值与持仓\n") |
3016 | | - for code in funds: |
3017 | | - run_fund_report(code, date_str, include_news=include_news) |
| 2981 | + _print_daily_watchlist(sys.modules[__name__], watchlist, date_str, include_news=include_news) |
3018 | 2982 |
|
3019 | 2983 |
|
3020 | 2984 | def run_daily_report( |
3021 | 2985 | date_str: str, |
3022 | 2986 | watchlist: dict[str, list[str]] | None = None, |
3023 | 2987 | include_news: bool = True, |
3024 | 2988 | ) -> None: |
3025 | | - DIAGNOSTICS.clear() |
3026 | | - display_date = _display_date(date_str) |
3027 | | - print(f"# 每日行情日报({display_date})\n") |
3028 | | - print_stage_line(date_str) |
3029 | | - print("数据来源: young-stock-cli 核心模块,多源免登录行情与新闻聚合。") |
3030 | | - print("说明: 以下内容仅供复盘参考,不构成投资建议。\n") |
3031 | | - print("=" * 60 + "\n") |
3032 | | - |
3033 | | - print_daily_watchlist(watchlist, date_str, include_news=include_news) |
3034 | | - |
3035 | | - print("## 二、全球指数与大盘概览\n") |
3036 | | - run_global_market(date_str) |
3037 | | - |
3038 | | - print("## 三、A股大盘与市场情绪\n") |
3039 | | - run_a_share(date_str, include_news=include_news) |
| 2989 | + from .reports import run_daily_report as _run_daily_report |
3040 | 2990 |
|
3041 | | - print("## 四、投资建议\n") |
3042 | | - print("- 先看交易日与数据源日期是否一致;若出现最新可用数据提示,不把旧行情当成当日信号。") |
3043 | | - print("- 个股/基金优先结合持仓成本、仓位上限和新闻催化验证,不因单日涨跌直接追涨杀跌。") |
3044 | | - print("- 市场情绪偏弱或资金流口径降级时,降低加仓冲动;情绪修复时再观察量能和领涨方向持续性。") |
3045 | | - print("- 基金估值是盘中/收盘估算,正式净值以基金公司晚间披露为准。") |
3046 | | - print() |
3047 | | - |
3048 | | - if DIAGNOSTICS: |
3049 | | - print_diagnostic_summary() |
3050 | | - print_report_footer() |
| 2991 | + _run_daily_report(sys.modules[__name__], date_str, watchlist, include_news=include_news) |
3051 | 2992 |
|
3052 | 2993 |
|
3053 | 2994 | def print_futu_news(news_data: dict, keyword: str, limit: int = 5) -> None: |
@@ -3219,16 +3160,7 @@ def print_a_share_news(date_str: str, zt_data: dict | None = None) -> None: |
3219 | 3160 | # ------------------------------------------------------------------ |
3220 | 3161 |
|
3221 | 3162 | def nearest_trade_date(dt: datetime | None = None) -> str: |
3222 | | - if dt is None: |
3223 | | - dt = datetime.now() |
3224 | | - if dt.weekday() < 5 and (dt.hour, dt.minute) < (15, 0): |
3225 | | - dt -= timedelta(days=1) |
3226 | | - wd = dt.weekday() |
3227 | | - if wd == 5: |
3228 | | - dt -= timedelta(days=1) |
3229 | | - elif wd == 6: |
3230 | | - dt -= timedelta(days=2) |
3231 | | - return dt.strftime("%Y%m%d") |
| 3163 | + return calendar_nearest_trade_date(dt) |
3232 | 3164 |
|
3233 | 3165 |
|
3234 | 3166 | def _session_label() -> str: |
|
0 commit comments