aria-code 4.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- workspace/verify.py +113 -0
report_generator.py
ADDED
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
"""
|
|
2
|
+
report_generator.py — 专业研报生成引擎
|
|
3
|
+
=======================================
|
|
4
|
+
输入: symbol + TeamResult(可选)
|
|
5
|
+
输出: 单文件 HTML(内嵌图表 base64)→ 可直接在浏览器打印为 PDF
|
|
6
|
+
|
|
7
|
+
特性:
|
|
8
|
+
· Bloomberg 风格暗色主题
|
|
9
|
+
· mplfinance K线图 + 成交量(fallback: 收盘价折线)
|
|
10
|
+
· 数据清洗质量报告(来自 data_cleaner.py)
|
|
11
|
+
· 多 Agent 分析卡片
|
|
12
|
+
· 关键财务指标表格
|
|
13
|
+
· 完全离线,无外部 CDN 依赖
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import html
|
|
21
|
+
import io
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import math
|
|
25
|
+
import re as _re
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
from artifacts import create_user_artifact, write_artifact_metadata, write_artifact_raw_data
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import pandas as pd
|
|
34
|
+
except ImportError:
|
|
35
|
+
pd = None # type: ignore
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _history_records_to_df(records: List[Dict[str, Any]]):
|
|
41
|
+
import pandas as _pd
|
|
42
|
+
if not records:
|
|
43
|
+
return _pd.DataFrame()
|
|
44
|
+
df = _pd.DataFrame(records)
|
|
45
|
+
if df.empty or "date" not in df.columns:
|
|
46
|
+
return _pd.DataFrame()
|
|
47
|
+
df["date"] = _pd.to_datetime(df["date"], errors="coerce")
|
|
48
|
+
df = df.dropna(subset=["date"]).set_index("date").sort_index()
|
|
49
|
+
rename = {
|
|
50
|
+
"open": "Open", "high": "High", "low": "Low",
|
|
51
|
+
"close": "Close", "volume": "Volume",
|
|
52
|
+
}
|
|
53
|
+
df = df.rename(columns={k: v for k, v in rename.items() if k in df.columns})
|
|
54
|
+
for col in ("Open", "High", "Low", "Close", "Volume"):
|
|
55
|
+
if col not in df.columns:
|
|
56
|
+
df[col] = 0
|
|
57
|
+
df[col] = _pd.to_numeric(df[col], errors="coerce")
|
|
58
|
+
df = df.dropna(subset=["Close"])
|
|
59
|
+
return df[["Open", "High", "Low", "Close", "Volume"]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _merge_present(target: Dict[str, Any], source: Dict[str, Any], keys: List[str]) -> None:
|
|
63
|
+
for key in keys:
|
|
64
|
+
value = source.get(key)
|
|
65
|
+
if value not in (None, "", [], {}):
|
|
66
|
+
target[key] = value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fetch_report_data_sync(symbol: str) -> Tuple[Any, Any, Dict[str, Any]]:
|
|
70
|
+
"""Fetch report data with fallback providers and source diagnostics."""
|
|
71
|
+
from data_cleaner import CleanResult, clean_price_series, get_clean_prices, get_fundamentals
|
|
72
|
+
import pandas as _pd
|
|
73
|
+
|
|
74
|
+
provider_chain: List[str] = []
|
|
75
|
+
data_warnings: List[str] = []
|
|
76
|
+
df = _pd.DataFrame()
|
|
77
|
+
clean_result = CleanResult(df, quality_score=0.0)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
df, clean_result = get_clean_prices(symbol, period="1y")
|
|
81
|
+
if df is not None and not df.empty:
|
|
82
|
+
provider_chain.append("data_cleaner")
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
data_warnings.append(f"data_cleaner prices: {exc}")
|
|
85
|
+
df = _pd.DataFrame()
|
|
86
|
+
clean_result = CleanResult(df, quality_score=0.0)
|
|
87
|
+
|
|
88
|
+
fundamentals: Dict[str, Any]
|
|
89
|
+
try:
|
|
90
|
+
fundamentals = get_fundamentals(symbol)
|
|
91
|
+
provider_chain.append("fundamentals")
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
data_warnings.append(f"fundamentals: {exc}")
|
|
94
|
+
fundamentals = {
|
|
95
|
+
"company_name": symbol,
|
|
96
|
+
"symbol": symbol,
|
|
97
|
+
"currency": "CNY" if str(symbol).isdigit() and len(str(symbol)) == 6 else "USD",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
from packages.aria_services.data import DataService
|
|
102
|
+
bundle = DataService().bundle(symbol, history_days=370, technical_days=120)
|
|
103
|
+
|
|
104
|
+
hist = bundle.history
|
|
105
|
+
if (df is None or df.empty) and hist.get("success"):
|
|
106
|
+
fallback_df = _history_records_to_df(hist.get("data") or [])
|
|
107
|
+
if not fallback_df.empty:
|
|
108
|
+
df = fallback_df
|
|
109
|
+
clean_result = clean_price_series(df, symbol)
|
|
110
|
+
elif not hist.get("success"):
|
|
111
|
+
data_warnings.append(hist.get("error") or "history unavailable")
|
|
112
|
+
|
|
113
|
+
quote = bundle.quote
|
|
114
|
+
if quote.get("success"):
|
|
115
|
+
_merge_present(
|
|
116
|
+
fundamentals,
|
|
117
|
+
quote,
|
|
118
|
+
["price", "prev_close", "open", "high", "low", "volume", "turnover",
|
|
119
|
+
"market_cap", "currency", "name"],
|
|
120
|
+
)
|
|
121
|
+
q_name = quote.get("name")
|
|
122
|
+
cur_name = fundamentals.get("company_name", "")
|
|
123
|
+
# Prefer Chinese name from market data over English fallback from yfinance.
|
|
124
|
+
# Override when: name is missing, is the bare symbol, or is pure ASCII
|
|
125
|
+
# (yfinance fallback) while the quote provides a localized name.
|
|
126
|
+
if q_name and (
|
|
127
|
+
cur_name in (None, "", symbol)
|
|
128
|
+
or (cur_name.isascii() and not q_name.isascii())
|
|
129
|
+
):
|
|
130
|
+
fundamentals["company_name"] = q_name
|
|
131
|
+
else:
|
|
132
|
+
data_warnings.append(quote.get("error") or "quote unavailable")
|
|
133
|
+
|
|
134
|
+
fund = bundle.fundamentals
|
|
135
|
+
if fund.get("success"):
|
|
136
|
+
_merge_present(
|
|
137
|
+
fundamentals,
|
|
138
|
+
fund,
|
|
139
|
+
["sector", "industry", "market_cap", "pe_ratio", "pe_ttm",
|
|
140
|
+
"pb_ratio", "pb", "ps_ratio", "roe", "revenue", "net_income",
|
|
141
|
+
"eps", "dividend_yield", "52w_high", "52w_low", "description"],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
ti = bundle.technical
|
|
145
|
+
if ti.get("success"):
|
|
146
|
+
_merge_present(
|
|
147
|
+
fundamentals,
|
|
148
|
+
ti,
|
|
149
|
+
["rsi", "macd", "signal", "ma5", "ma10", "ma20", "ma60",
|
|
150
|
+
"bb_upper", "bb_lower", "price"],
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
data_warnings.append(ti.get("error") or "technical indicators unavailable")
|
|
154
|
+
|
|
155
|
+
provider_chain.extend(bundle.provider_chain)
|
|
156
|
+
data_warnings.extend(bundle.warnings)
|
|
157
|
+
data_warnings.extend(bundle.errors)
|
|
158
|
+
if bundle.missing_fields:
|
|
159
|
+
data_warnings.append("missing fields: " + ", ".join(bundle.missing_fields))
|
|
160
|
+
fundamentals["data_status"] = bundle.status
|
|
161
|
+
fundamentals["data_quality"] = bundle.quality
|
|
162
|
+
fundamentals["data_stale"] = bool(bundle.quality.get("stale") if bundle.quality else False)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
data_warnings.append(f"data_service: {exc}")
|
|
165
|
+
|
|
166
|
+
if df is not None and not df.empty:
|
|
167
|
+
try:
|
|
168
|
+
close_series = df["Close"].dropna()
|
|
169
|
+
last_close = float(close_series.iloc[-1])
|
|
170
|
+
fundamentals.setdefault("price", last_close)
|
|
171
|
+
if len(close_series) >= 2:
|
|
172
|
+
fundamentals.setdefault("prev_close", float(close_series.iloc[-2]))
|
|
173
|
+
trailing_year = close_series.tail(252)
|
|
174
|
+
fundamentals.setdefault("52w_high", float(trailing_year.max()))
|
|
175
|
+
fundamentals.setdefault("52w_low", float(trailing_year.min()))
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
fundamentals["data_provider_chain"] = list(dict.fromkeys(str(p) for p in provider_chain if p))
|
|
180
|
+
fundamentals["data_warnings"] = data_warnings[:6]
|
|
181
|
+
fundamentals.setdefault("company_name", symbol)
|
|
182
|
+
fundamentals.setdefault("symbol", symbol)
|
|
183
|
+
return df, clean_result, fundamentals
|
|
184
|
+
|
|
185
|
+
# ── Signal styles ─────────────────────────────────────────────────────────────
|
|
186
|
+
_SIGNAL_COLOR = {
|
|
187
|
+
"STRONG_BUY": ("#0d3b1e", "#3fb950", "▲▲"),
|
|
188
|
+
"BUY": ("#0d3b1e", "#3fb950", "▲ "),
|
|
189
|
+
"HOLD": ("#1f2836", "#79c0ff", "─ "),
|
|
190
|
+
"SELL": ("#3d1218", "#f85149", "▼ "),
|
|
191
|
+
"STRONG_SELL":("#3d1218", "#f85149", "▼▼"),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _sig_style(signal: str):
|
|
196
|
+
return _SIGNAL_COLOR.get((signal or "HOLD").upper(),
|
|
197
|
+
("#1f2836", "#79c0ff", "─ "))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── Chart Generation ──────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def _chart_to_b64(fig) -> str:
|
|
203
|
+
import matplotlib.pyplot as plt
|
|
204
|
+
buf = io.BytesIO()
|
|
205
|
+
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight",
|
|
206
|
+
facecolor=fig.get_facecolor())
|
|
207
|
+
buf.seek(0)
|
|
208
|
+
b64 = base64.b64encode(buf.read()).decode()
|
|
209
|
+
plt.close(fig)
|
|
210
|
+
return b64
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def generate_price_chart(df, symbol: str, fundamentals: Dict) -> Optional[str]:
|
|
214
|
+
"""
|
|
215
|
+
Returns base64-encoded PNG of a dark-theme candlestick chart.
|
|
216
|
+
Falls back to a line chart if mplfinance is unavailable.
|
|
217
|
+
"""
|
|
218
|
+
if df is None or df.empty:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
# Need at minimum Close column
|
|
222
|
+
close_col = next((c for c in df.columns if c.lower() == "close"), None)
|
|
223
|
+
if not close_col:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
# Ensure DatetimeIndex
|
|
227
|
+
import pandas as _pd
|
|
228
|
+
try:
|
|
229
|
+
if not isinstance(df.index, _pd.DatetimeIndex):
|
|
230
|
+
df.index = _pd.to_datetime(df.index)
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
# Use last 6 months
|
|
235
|
+
df6 = df.tail(126).copy()
|
|
236
|
+
|
|
237
|
+
# ── mplfinance path ────────────────────────────────────────────────────
|
|
238
|
+
try:
|
|
239
|
+
import matplotlib
|
|
240
|
+
matplotlib.use("Agg")
|
|
241
|
+
import mplfinance as mpf
|
|
242
|
+
import matplotlib.pyplot as plt
|
|
243
|
+
|
|
244
|
+
mc = mpf.make_marketcolors(
|
|
245
|
+
up="#3fb950", down="#f85149", edge="inherit",
|
|
246
|
+
wick={"up": "#3fb950", "down": "#f85149"},
|
|
247
|
+
volume={"up": "#1e4d2b", "down": "#4d1219"},
|
|
248
|
+
)
|
|
249
|
+
style = mpf.make_mpf_style(
|
|
250
|
+
marketcolors=mc,
|
|
251
|
+
facecolor="#0d1117",
|
|
252
|
+
edgecolor="#21262d",
|
|
253
|
+
figcolor="#0d1117",
|
|
254
|
+
gridcolor="#161b22",
|
|
255
|
+
gridstyle="--",
|
|
256
|
+
rc={
|
|
257
|
+
"axes.labelcolor": "#8b949e",
|
|
258
|
+
"axes.edgecolor": "#21262d",
|
|
259
|
+
"xtick.color": "#8b949e",
|
|
260
|
+
"ytick.color": "#8b949e",
|
|
261
|
+
"font.size": 9,
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
has_ohlcv = all(c in df6.columns for c in ("Open", "High", "Low", "Close", "Volume"))
|
|
266
|
+
plot_type = "candle" if has_ohlcv else "line"
|
|
267
|
+
addplots = []
|
|
268
|
+
|
|
269
|
+
if "Close" in df6.columns:
|
|
270
|
+
ma20 = df6["Close"].rolling(20).mean()
|
|
271
|
+
ma50 = df6["Close"].rolling(50).mean()
|
|
272
|
+
if not ma20.dropna().empty:
|
|
273
|
+
addplots.append(mpf.make_addplot(ma20, color="#388bfd", width=1.2,
|
|
274
|
+
label="MA20"))
|
|
275
|
+
if not ma50.dropna().empty:
|
|
276
|
+
addplots.append(mpf.make_addplot(ma50, color="#8957e5", width=1.2,
|
|
277
|
+
label="MA50"))
|
|
278
|
+
|
|
279
|
+
kwargs: Dict[str, Any] = dict(
|
|
280
|
+
type=plot_type,
|
|
281
|
+
style=style,
|
|
282
|
+
figsize=(11, 5.5),
|
|
283
|
+
returnfig=True,
|
|
284
|
+
datetime_format="%m/%d",
|
|
285
|
+
xrotation=0,
|
|
286
|
+
)
|
|
287
|
+
if has_ohlcv:
|
|
288
|
+
kwargs["volume"] = True
|
|
289
|
+
kwargs["volume_panel"] = 1
|
|
290
|
+
kwargs["panel_ratios"] = (3, 1)
|
|
291
|
+
if addplots:
|
|
292
|
+
kwargs["addplot"] = addplots
|
|
293
|
+
|
|
294
|
+
fig, axes = mpf.plot(df6, **kwargs)
|
|
295
|
+
|
|
296
|
+
# Title
|
|
297
|
+
price = fundamentals.get("price")
|
|
298
|
+
p_str = f" ${price:,.2f}" if price else ""
|
|
299
|
+
axes[0].set_title(
|
|
300
|
+
f"{symbol}{p_str} · 6-Month Price History",
|
|
301
|
+
color="#c9d1d9", fontsize=11, pad=8,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return _chart_to_b64(fig)
|
|
305
|
+
|
|
306
|
+
except ImportError:
|
|
307
|
+
pass
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.debug("[report] mplfinance chart: %s", e)
|
|
310
|
+
|
|
311
|
+
# ── matplotlib fallback: line chart ────────────────────────────────────
|
|
312
|
+
try:
|
|
313
|
+
import matplotlib
|
|
314
|
+
matplotlib.use("Agg")
|
|
315
|
+
import matplotlib.pyplot as plt
|
|
316
|
+
|
|
317
|
+
fig, ax = plt.subplots(figsize=(11, 4), facecolor="#0d1117")
|
|
318
|
+
ax.set_facecolor("#0d1117")
|
|
319
|
+
close = df6["Close"] if "Close" in df6.columns else df6.iloc[:, 0]
|
|
320
|
+
ax.plot(df6.index, close, color="#3fb950", linewidth=1.5)
|
|
321
|
+
|
|
322
|
+
# MA lines
|
|
323
|
+
ma20 = close.rolling(20).mean()
|
|
324
|
+
ma50 = close.rolling(50).mean()
|
|
325
|
+
ax.plot(df6.index, ma20, color="#388bfd", linewidth=1.0, alpha=0.8, label="MA20")
|
|
326
|
+
ax.plot(df6.index, ma50, color="#8957e5", linewidth=1.0, alpha=0.8, label="MA50")
|
|
327
|
+
|
|
328
|
+
ax.set_title(f"{symbol} · 6-Month Close Price",
|
|
329
|
+
color="#c9d1d9", fontsize=11)
|
|
330
|
+
ax.tick_params(colors="#8b949e")
|
|
331
|
+
ax.spines[:].set_edgecolor("#21262d")
|
|
332
|
+
ax.grid(color="#161b22", linestyle="--", linewidth=0.5)
|
|
333
|
+
ax.legend(facecolor="#161b22", edgecolor="#21262d",
|
|
334
|
+
labelcolor="#8b949e", fontsize=9)
|
|
335
|
+
fig.tight_layout()
|
|
336
|
+
return _chart_to_b64(fig)
|
|
337
|
+
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.debug("[report] matplotlib fallback: %s", e)
|
|
340
|
+
return _generate_svg_line_chart(df6, symbol)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _generate_svg_line_chart(df, symbol: str) -> Optional[str]:
|
|
344
|
+
"""Dependency-free inline SVG fallback for environments without chart libs."""
|
|
345
|
+
try:
|
|
346
|
+
if df is None or df.empty:
|
|
347
|
+
return None
|
|
348
|
+
close_col = next((c for c in df.columns if c.lower() == "close"), None)
|
|
349
|
+
if not close_col:
|
|
350
|
+
return None
|
|
351
|
+
values = [float(v) for v in df[close_col].dropna().tail(126).tolist() if math.isfinite(float(v))]
|
|
352
|
+
if len(values) < 2:
|
|
353
|
+
return None
|
|
354
|
+
width, height, pad = 920, 320, 34
|
|
355
|
+
lo, hi = min(values), max(values)
|
|
356
|
+
if math.isclose(lo, hi):
|
|
357
|
+
lo *= 0.99
|
|
358
|
+
hi *= 1.01
|
|
359
|
+
pts = []
|
|
360
|
+
for i, value in enumerate(values):
|
|
361
|
+
x = pad + (width - pad * 2) * i / max(len(values) - 1, 1)
|
|
362
|
+
y = pad + (height - pad * 2) * (1 - (value - lo) / (hi - lo))
|
|
363
|
+
pts.append(f"{x:.1f},{y:.1f}")
|
|
364
|
+
return f"""
|
|
365
|
+
<svg viewBox="0 0 {width} {height}" role="img" aria-label="{_e(symbol)} price chart" class="inline-chart">
|
|
366
|
+
<rect x="0" y="0" width="{width}" height="{height}" rx="8" fill="#0d1117"/>
|
|
367
|
+
<line x1="{pad}" y1="{height-pad}" x2="{width-pad}" y2="{height-pad}" stroke="#21262d"/>
|
|
368
|
+
<line x1="{pad}" y1="{pad}" x2="{pad}" y2="{height-pad}" stroke="#21262d"/>
|
|
369
|
+
<polyline points="{" ".join(pts)}" fill="none" stroke="#c08050" stroke-width="3"/>
|
|
370
|
+
<text x="{pad}" y="{pad - 10}" fill="#8b949e" font-size="12">{_e(symbol)} Close</text>
|
|
371
|
+
<text x="{width-pad-150}" y="{pad - 10}" fill="#8b949e" font-size="12">{values[-1]:,.2f}</text>
|
|
372
|
+
</svg>"""
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.debug("[report] svg fallback: %s", e)
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ── Number Formatting ─────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def _fmt(val, precision: int = 2, pct: bool = False,
|
|
381
|
+
currency: str = "", na: str = "—") -> str:
|
|
382
|
+
if val is None:
|
|
383
|
+
return na
|
|
384
|
+
try:
|
|
385
|
+
v = float(val)
|
|
386
|
+
except (TypeError, ValueError):
|
|
387
|
+
return na
|
|
388
|
+
if not math.isfinite(v):
|
|
389
|
+
return na
|
|
390
|
+
|
|
391
|
+
if pct:
|
|
392
|
+
return f"{v * 100:.{precision}f}%"
|
|
393
|
+
if currency:
|
|
394
|
+
if abs(v) >= 1e12:
|
|
395
|
+
return f"{currency}{v/1e12:.2f}T"
|
|
396
|
+
if abs(v) >= 1e9:
|
|
397
|
+
return f"{currency}{v/1e9:.2f}B"
|
|
398
|
+
if abs(v) >= 1e6:
|
|
399
|
+
return f"{currency}{v/1e6:.2f}M"
|
|
400
|
+
return f"{currency}{v:,.{precision}f}"
|
|
401
|
+
return f"{v:,.{precision}f}"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _fmt_metric(val, precision: int = 2, pct: bool = False,
|
|
405
|
+
currency: str = "", zero_is_missing: bool = False) -> str:
|
|
406
|
+
try:
|
|
407
|
+
v = float(val)
|
|
408
|
+
if zero_is_missing and abs(v) < 1e-12:
|
|
409
|
+
return "—"
|
|
410
|
+
except (TypeError, ValueError):
|
|
411
|
+
pass
|
|
412
|
+
return _fmt(val, precision=precision, pct=pct, currency=currency)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _color_val(val, good_positive: bool = True) -> str:
|
|
416
|
+
"""Return HTML color for a numeric value."""
|
|
417
|
+
try:
|
|
418
|
+
v = float(val)
|
|
419
|
+
if v > 0:
|
|
420
|
+
return "#3fb950" if good_positive else "#f85149"
|
|
421
|
+
if v < 0:
|
|
422
|
+
return "#f85149" if good_positive else "#3fb950"
|
|
423
|
+
except (TypeError, ValueError):
|
|
424
|
+
pass
|
|
425
|
+
return "#8b949e"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ── HTML Builder ──────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
def _e(text: str) -> str:
|
|
431
|
+
"""HTML-escape user-facing strings."""
|
|
432
|
+
return html.escape(str(text or ""))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _md_to_html(text: str, max_chars: int = 0) -> str:
|
|
436
|
+
"""Convert LLM-generated markdown to safe HTML for embedding in reports.
|
|
437
|
+
|
|
438
|
+
Order of operations: escape → fix <br> → tables → inline styles → newlines.
|
|
439
|
+
"""
|
|
440
|
+
if not text:
|
|
441
|
+
return ""
|
|
442
|
+
if max_chars and len(text) > max_chars:
|
|
443
|
+
text = text[:max_chars] + "…"
|
|
444
|
+
|
|
445
|
+
# 1. HTML-escape all user content first (XSS prevention)
|
|
446
|
+
t = html.escape(text)
|
|
447
|
+
|
|
448
|
+
# 2. LLMs sometimes emit literal <br> inside markdown — convert to newline
|
|
449
|
+
t = t.replace("<br>", "\n")
|
|
450
|
+
|
|
451
|
+
# 3. Markdown tables → HTML tables
|
|
452
|
+
# Pre-join continuation lines: table cells may contain \n (from <br>).
|
|
453
|
+
# A non-pipe line immediately following a pipe line is part of that row's cell.
|
|
454
|
+
raw_lines = t.split("\n")
|
|
455
|
+
merged: List[str] = []
|
|
456
|
+
for raw_line in raw_lines:
|
|
457
|
+
if merged and merged[-1].lstrip().startswith("|") and raw_line and not raw_line.lstrip().startswith("|"):
|
|
458
|
+
merged[-1] += "<br>" + raw_line
|
|
459
|
+
else:
|
|
460
|
+
merged.append(raw_line)
|
|
461
|
+
|
|
462
|
+
out_lines: List[str] = []
|
|
463
|
+
i = 0
|
|
464
|
+
while i < len(merged):
|
|
465
|
+
line = merged[i]
|
|
466
|
+
# Detect header row: has | and next line is a separator (|---|---|)
|
|
467
|
+
if (
|
|
468
|
+
"|" in line
|
|
469
|
+
and i + 1 < len(merged)
|
|
470
|
+
and _re.match(r"^\s*\|[\s\-:|]+\|", merged[i + 1])
|
|
471
|
+
):
|
|
472
|
+
header_cells = [c.strip() for c in line.strip("|").split("|")]
|
|
473
|
+
th = "".join(f"<th>{c}</th>" for c in header_cells)
|
|
474
|
+
i += 2 # skip header + separator
|
|
475
|
+
tbody_rows = ""
|
|
476
|
+
while i < len(merged) and "|" in merged[i] and merged[i].lstrip().startswith("|"):
|
|
477
|
+
cells = [c.strip() for c in merged[i].strip("|").split("|")]
|
|
478
|
+
tbody_rows += "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
|
|
479
|
+
i += 1
|
|
480
|
+
out_lines.append(
|
|
481
|
+
f'<table class="md-table">'
|
|
482
|
+
f"<thead><tr>{th}</tr></thead>"
|
|
483
|
+
f"<tbody>{tbody_rows}</tbody>"
|
|
484
|
+
f"</table>"
|
|
485
|
+
)
|
|
486
|
+
else:
|
|
487
|
+
out_lines.append(line)
|
|
488
|
+
i += 1
|
|
489
|
+
t = "\n".join(out_lines)
|
|
490
|
+
|
|
491
|
+
# 4. Bold: **text** or __text__
|
|
492
|
+
t = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", t, flags=_re.DOTALL)
|
|
493
|
+
t = _re.sub(r"__(.+?)__", r"<strong>\1</strong>", t, flags=_re.DOTALL)
|
|
494
|
+
|
|
495
|
+
# 5. Italic: *text* (not **)
|
|
496
|
+
t = _re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", t)
|
|
497
|
+
|
|
498
|
+
# 6. Inline code
|
|
499
|
+
t = _re.sub(r"`([^`]+)`", r"<code>\1</code>", t)
|
|
500
|
+
|
|
501
|
+
# 7. H2/H3 headers (## Heading → styled paragraph)
|
|
502
|
+
t = _re.sub(r"^#{1,3}\s+(.+)$", r'<strong class="md-h">\1</strong>', t, flags=_re.MULTILINE)
|
|
503
|
+
|
|
504
|
+
# 8. Horizontal rules (--- or ***)
|
|
505
|
+
t = _re.sub(r"\n[-*]{3,}\n", '\n<hr class="md-hr">\n', t)
|
|
506
|
+
|
|
507
|
+
# 9. Newlines → <br> (done last so table/hr markup isn't broken)
|
|
508
|
+
t = t.replace("\n", "<br>\n")
|
|
509
|
+
|
|
510
|
+
return t
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _kpi_card(label: str, value: str, sub: str = "", color: str = "#e6edf3") -> str:
|
|
514
|
+
return f"""
|
|
515
|
+
<div class="kpi-card">
|
|
516
|
+
<div class="kpi-label">{_e(label)}</div>
|
|
517
|
+
<div class="kpi-value" style="color:{color}">{_e(value)}</div>
|
|
518
|
+
{f'<div class="kpi-sub">{_e(sub)}</div>' if sub else ""}
|
|
519
|
+
</div>"""
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _agent_card(agent_name: str, signal: str, confidence: float,
|
|
523
|
+
analysis: str, key_points: List[str]) -> str:
|
|
524
|
+
bg, accent, icon = _sig_style(signal)
|
|
525
|
+
kps = "".join(f'<li>{_md_to_html(kp)}</li>' for kp in (key_points or [])[:4])
|
|
526
|
+
analysis_html = _md_to_html(analysis or "", max_chars=3000)
|
|
527
|
+
return f"""
|
|
528
|
+
<div class="agent-card" style="border-color:{accent}40;">
|
|
529
|
+
<div class="agent-header">
|
|
530
|
+
<span class="agent-name">{_e(agent_name.upper())}</span>
|
|
531
|
+
<span class="agent-signal" style="color:{accent};background:{bg};">
|
|
532
|
+
{icon} {_e(signal)}
|
|
533
|
+
</span>
|
|
534
|
+
<span class="agent-conf" style="color:{accent};">{confidence:.0%}</span>
|
|
535
|
+
</div>
|
|
536
|
+
{f'<ul class="agent-kps">{kps}</ul>' if kps else ""}
|
|
537
|
+
{f'<div class="agent-analysis">{analysis_html}</div>' if analysis_html else ""}
|
|
538
|
+
</div>"""
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _metrics_row(label: str, value: str, highlight: bool = False) -> str:
|
|
542
|
+
style = "background:#161b22;" if highlight else ""
|
|
543
|
+
return (f'<tr style="{style}">'
|
|
544
|
+
f'<td class="metric-label">{_e(label)}</td>'
|
|
545
|
+
f'<td class="metric-value">{_e(value)}</td>'
|
|
546
|
+
f'</tr>')
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _build_html(
|
|
550
|
+
symbol: str,
|
|
551
|
+
fundamentals: Dict,
|
|
552
|
+
price_chart: Optional[str],
|
|
553
|
+
team_result,
|
|
554
|
+
clean_result,
|
|
555
|
+
) -> str:
|
|
556
|
+
fund = fundamentals
|
|
557
|
+
name = fund.get("company_name", symbol)
|
|
558
|
+
cur = fund.get("currency", "USD")
|
|
559
|
+
cur_sym = "¥" if cur in ("CNY","CNH","HKD") else "$"
|
|
560
|
+
price = fund.get("price")
|
|
561
|
+
prev = fund.get("prev_close")
|
|
562
|
+
|
|
563
|
+
# Price change
|
|
564
|
+
if price and prev and prev > 0:
|
|
565
|
+
chg = price - prev
|
|
566
|
+
chg_pct = chg / prev * 100
|
|
567
|
+
if abs(chg) < 0.001:
|
|
568
|
+
chg_str = "—"
|
|
569
|
+
chg_color = "#8b949e"
|
|
570
|
+
else:
|
|
571
|
+
chg_str = f"{chg:+.2f} ({chg_pct:+.2f}%)"
|
|
572
|
+
chg_color = "#3fb950" if chg >= 0 else "#f85149"
|
|
573
|
+
else:
|
|
574
|
+
chg_str = "—"
|
|
575
|
+
chg_color = "#8b949e"
|
|
576
|
+
|
|
577
|
+
# Final signal from team result
|
|
578
|
+
final_signal = "HOLD"
|
|
579
|
+
confidence = 0.0
|
|
580
|
+
synthesis = ""
|
|
581
|
+
agent_cards = ""
|
|
582
|
+
|
|
583
|
+
if team_result:
|
|
584
|
+
final_signal = team_result.final_signal or "HOLD"
|
|
585
|
+
confidence = team_result.confidence or 0.0
|
|
586
|
+
synthesis = team_result.synthesis or ""
|
|
587
|
+
cards = []
|
|
588
|
+
for r in (team_result.results or []):
|
|
589
|
+
if not r or r.agent == "debate":
|
|
590
|
+
continue
|
|
591
|
+
cards.append(_agent_card(
|
|
592
|
+
r.agent, r.signal or "HOLD", r.confidence,
|
|
593
|
+
r.analysis, r.key_points,
|
|
594
|
+
))
|
|
595
|
+
agent_cards = "\n".join(cards)
|
|
596
|
+
|
|
597
|
+
sig_bg, sig_accent, sig_icon = _sig_style(final_signal)
|
|
598
|
+
|
|
599
|
+
# KPI cards
|
|
600
|
+
mkt_cap_str = _fmt(fund.get("market_cap"), currency=cur_sym)
|
|
601
|
+
pe_str = _fmt_metric(fund.get("pe_ratio"), precision=1, zero_is_missing=True)
|
|
602
|
+
pb_str = _fmt_metric(fund.get("pb_ratio"), precision=2, zero_is_missing=True)
|
|
603
|
+
beta_str = _fmt(fund.get("beta"), precision=2)
|
|
604
|
+
w52_high = _fmt(fund.get("52w_high"), precision=2, currency=cur_sym)
|
|
605
|
+
w52_low = _fmt(fund.get("52w_low"), precision=2, currency=cur_sym)
|
|
606
|
+
|
|
607
|
+
kpis = "".join([
|
|
608
|
+
_kpi_card("当前价格",
|
|
609
|
+
_fmt(price, precision=2, currency=cur_sym),
|
|
610
|
+
chg_str, chg_color),
|
|
611
|
+
_kpi_card("市值", mkt_cap_str),
|
|
612
|
+
_kpi_card("市盈率 (TTM)", pe_str),
|
|
613
|
+
_kpi_card("市净率", pb_str),
|
|
614
|
+
_kpi_card("Beta", beta_str),
|
|
615
|
+
_kpi_card("52周区间", f"{w52_low} – {w52_high}"),
|
|
616
|
+
])
|
|
617
|
+
|
|
618
|
+
# Metrics table — two column groups
|
|
619
|
+
roe = _fmt_metric(fund.get("roe"), precision=1, pct=True, zero_is_missing=True)
|
|
620
|
+
roa = _fmt_metric(fund.get("roa"), precision=1, pct=True, zero_is_missing=True)
|
|
621
|
+
gm = _fmt_metric(fund.get("gross_margin"), precision=1, pct=True, zero_is_missing=True)
|
|
622
|
+
om = _fmt_metric(fund.get("operating_margin"), precision=1, pct=True, zero_is_missing=True)
|
|
623
|
+
nm = _fmt_metric(fund.get("net_margin"), precision=1, pct=True, zero_is_missing=True)
|
|
624
|
+
rev_g = _fmt_metric(fund.get("revenue_growth"), precision=1, pct=True, zero_is_missing=True)
|
|
625
|
+
de = _fmt_metric(fund.get("debt_equity"), precision=2, zero_is_missing=True)
|
|
626
|
+
cr = _fmt_metric(fund.get("current_ratio"), precision=2, zero_is_missing=True)
|
|
627
|
+
dy = _fmt_metric(fund.get("dividend_yield"), precision=2, pct=True, zero_is_missing=True)
|
|
628
|
+
at = _fmt(fund.get("analyst_target"), precision=2, currency=cur_sym)
|
|
629
|
+
ac = fund.get("analyst_count") or "—"
|
|
630
|
+
rec = (fund.get("recommendation") or "").upper().replace("_"," ")
|
|
631
|
+
rsi = _fmt_metric(fund.get("rsi"), precision=1)
|
|
632
|
+
macd = _fmt_metric(fund.get("macd"), precision=3)
|
|
633
|
+
signal = _fmt_metric(fund.get("signal"), precision=3)
|
|
634
|
+
ma20 = _fmt(fund.get("ma20"), precision=2, currency=cur_sym)
|
|
635
|
+
ma60 = _fmt(fund.get("ma60"), precision=2, currency=cur_sym)
|
|
636
|
+
bb_upper = _fmt(fund.get("bb_upper"), precision=2, currency=cur_sym)
|
|
637
|
+
bb_lower = _fmt(fund.get("bb_lower"), precision=2, currency=cur_sym)
|
|
638
|
+
|
|
639
|
+
metrics_rows = "".join([
|
|
640
|
+
_metrics_row("收益/盈利", "", highlight=True),
|
|
641
|
+
_metrics_row("ROE", roe),
|
|
642
|
+
_metrics_row("ROA", roa),
|
|
643
|
+
_metrics_row("毛利率", gm),
|
|
644
|
+
_metrics_row("营业利润率", om),
|
|
645
|
+
_metrics_row("净利率", nm),
|
|
646
|
+
_metrics_row("收入增速", rev_g),
|
|
647
|
+
_metrics_row("财务健康", "", highlight=True),
|
|
648
|
+
_metrics_row("负债/权益", de),
|
|
649
|
+
_metrics_row("流动比率", cr),
|
|
650
|
+
_metrics_row("股息率", dy),
|
|
651
|
+
_metrics_row("分析师评级", "", highlight=True),
|
|
652
|
+
_metrics_row("评级", rec or "—"),
|
|
653
|
+
_metrics_row("目标价", at),
|
|
654
|
+
_metrics_row("覆盖分析师", str(ac)),
|
|
655
|
+
_metrics_row("技术指标", "", highlight=True),
|
|
656
|
+
_metrics_row("RSI(14)", rsi),
|
|
657
|
+
_metrics_row("MACD", macd),
|
|
658
|
+
_metrics_row("Signal", signal),
|
|
659
|
+
_metrics_row("MA20", ma20),
|
|
660
|
+
_metrics_row("MA60", ma60),
|
|
661
|
+
_metrics_row("布林上轨", bb_upper),
|
|
662
|
+
_metrics_row("布林下轨", bb_lower),
|
|
663
|
+
])
|
|
664
|
+
|
|
665
|
+
# Chart
|
|
666
|
+
chart_section = ""
|
|
667
|
+
if price_chart and price_chart.lstrip().startswith("<svg"):
|
|
668
|
+
chart_section = price_chart
|
|
669
|
+
elif price_chart:
|
|
670
|
+
chart_section = (
|
|
671
|
+
f'<img src="data:image/png;base64,{price_chart}" '
|
|
672
|
+
f'style="width:100%;border-radius:6px;" alt="Price Chart"/>'
|
|
673
|
+
)
|
|
674
|
+
else:
|
|
675
|
+
chart_section = '<p class="no-data">图表暂不可用:历史价格数据不足或本地绘图库不可用。</p>'
|
|
676
|
+
|
|
677
|
+
# Synthesis
|
|
678
|
+
synthesis_html = ""
|
|
679
|
+
if synthesis:
|
|
680
|
+
synthesis_html = f"""
|
|
681
|
+
<section class="card">
|
|
682
|
+
<div class="card-header">综合结论</div>
|
|
683
|
+
<div class="card-body synthesis-body">{_md_to_html(synthesis)}</div>
|
|
684
|
+
<div class="synthesis-footer">
|
|
685
|
+
<span style="color:{sig_accent}">{sig_icon} {_e(final_signal)}</span>
|
|
686
|
+
置信度 {confidence:.0%}
|
|
687
|
+
| 耗时 {getattr(team_result,"elapsed_sec",0):.1f}s
|
|
688
|
+
</div>
|
|
689
|
+
</section>"""
|
|
690
|
+
|
|
691
|
+
# Agent grid
|
|
692
|
+
agent_section = ""
|
|
693
|
+
if agent_cards:
|
|
694
|
+
agent_section = f"""
|
|
695
|
+
<section class="card">
|
|
696
|
+
<div class="card-header">多 Agent 研究团队</div>
|
|
697
|
+
<div class="agent-grid">{agent_cards}</div>
|
|
698
|
+
</section>"""
|
|
699
|
+
|
|
700
|
+
# Data quality section
|
|
701
|
+
quality_html = ""
|
|
702
|
+
if clean_result:
|
|
703
|
+
q = clean_result
|
|
704
|
+
if getattr(q, "df", None) is not None and not q.df.empty:
|
|
705
|
+
qc = "#3fb950" if q.quality_score >= 90 else (
|
|
706
|
+
"#d29922" if q.quality_score >= 70 else "#f85149")
|
|
707
|
+
quality_html = f"""
|
|
708
|
+
<section class="quality-bar">
|
|
709
|
+
<span>数据质量</span>
|
|
710
|
+
<span class="quality-score" style="color:{qc}">
|
|
711
|
+
{q.quality_score:.0f}/100
|
|
712
|
+
</span>
|
|
713
|
+
<span class="quality-detail">
|
|
714
|
+
{q.outlier_count} 异常 · {q.fill_count} 填充 ·
|
|
715
|
+
{q.real_gap_days} 缺口天
|
|
716
|
+
</span>
|
|
717
|
+
</section>"""
|
|
718
|
+
else:
|
|
719
|
+
quality_html = """
|
|
720
|
+
<section class="quality-bar">
|
|
721
|
+
<span>数据质量</span>
|
|
722
|
+
<span class="quality-score" style="color:#d29922">数据不足</span>
|
|
723
|
+
<span class="quality-detail">未取得足够历史行情,指标和图表已降级显示</span>
|
|
724
|
+
</section>"""
|
|
725
|
+
|
|
726
|
+
# Description
|
|
727
|
+
desc_html = ""
|
|
728
|
+
desc = (fund.get("description") or "").strip()
|
|
729
|
+
if desc:
|
|
730
|
+
desc_html = f"""
|
|
731
|
+
<section class="card">
|
|
732
|
+
<div class="card-header">公司简介</div>
|
|
733
|
+
<div class="card-body" style="font-size:13px;color:#8b949e;line-height:1.7">
|
|
734
|
+
{_e(desc)}
|
|
735
|
+
</div>
|
|
736
|
+
</section>"""
|
|
737
|
+
|
|
738
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
739
|
+
sector = fund.get("sector", "")
|
|
740
|
+
exch = fund.get("exchange", "")
|
|
741
|
+
sub = " · ".join(filter(None, [sector, exch, cur]))
|
|
742
|
+
source_chain = fund.get("data_provider_chain") or []
|
|
743
|
+
source_text = " → ".join(source_chain) if source_chain else "公开市场数据源"
|
|
744
|
+
warnings = fund.get("data_warnings") or []
|
|
745
|
+
warning_text = ""
|
|
746
|
+
if warnings:
|
|
747
|
+
warning_text = " · 数据降级: " + ";".join(str(w)[:120] for w in warnings[:3])
|
|
748
|
+
|
|
749
|
+
return _HTML_TEMPLATE.replace("{{CSS}}", _CSS) \
|
|
750
|
+
.replace("{{SYMBOL}}", _e(symbol)) \
|
|
751
|
+
.replace("{{COMPANY_NAME}}", _e(name)) \
|
|
752
|
+
.replace("{{SUBTITLE}}", _e(sub)) \
|
|
753
|
+
.replace("{{TIMESTAMP}}", _e(ts)) \
|
|
754
|
+
.replace("{{SIGNAL}}", _e(final_signal)) \
|
|
755
|
+
.replace("{{SIGNAL_BG}}", sig_bg) \
|
|
756
|
+
.replace("{{SIGNAL_ACCENT}}", sig_accent) \
|
|
757
|
+
.replace("{{SIGNAL_ICON}}", sig_icon) \
|
|
758
|
+
.replace("{{CONFIDENCE}}", f"{confidence:.0%}") \
|
|
759
|
+
.replace("{{KPI_CARDS}}", kpis) \
|
|
760
|
+
.replace("{{CHART_SECTION}}", chart_section) \
|
|
761
|
+
.replace("{{METRICS_ROWS}}", metrics_rows) \
|
|
762
|
+
.replace("{{AGENT_SECTION}}", agent_section) \
|
|
763
|
+
.replace("{{SYNTHESIS}}", synthesis_html) \
|
|
764
|
+
.replace("{{DESC_SECTION}}", desc_html) \
|
|
765
|
+
.replace("{{QUALITY_BAR}}", quality_html) \
|
|
766
|
+
.replace("{{DATA_SOURCE}}", _e(source_text + warning_text))
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
770
|
+
|
|
771
|
+
async def generate_report(
|
|
772
|
+
symbol: str,
|
|
773
|
+
team_result = None,
|
|
774
|
+
output_dir: Optional[Path] = None,
|
|
775
|
+
) -> Optional[Path]:
|
|
776
|
+
"""
|
|
777
|
+
Main entry point.
|
|
778
|
+
|
|
779
|
+
1. Fetch + clean price data via data_cleaner
|
|
780
|
+
2. Fetch fundamentals
|
|
781
|
+
3. Generate price chart
|
|
782
|
+
4. Render HTML with all data + agent analysis
|
|
783
|
+
5. Write to output_dir / {SYMBOL}_report_{timestamp}.html
|
|
784
|
+
"""
|
|
785
|
+
import pandas as _pd
|
|
786
|
+
|
|
787
|
+
ts_dt = datetime.now()
|
|
788
|
+
if output_dir:
|
|
789
|
+
out_dir = Path(output_dir)
|
|
790
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
791
|
+
artifact = None
|
|
792
|
+
else:
|
|
793
|
+
artifact = create_user_artifact("reports/market", symbol, f"{symbol}_market_report", ".html", timestamp=ts_dt)
|
|
794
|
+
out_dir = artifact.directory
|
|
795
|
+
|
|
796
|
+
logger.info("[report] generating %s", symbol)
|
|
797
|
+
|
|
798
|
+
# Fetch data (run in thread to avoid blocking the event loop)
|
|
799
|
+
loop = asyncio.get_event_loop()
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
df, clean_result, fundamentals = await loop.run_in_executor(
|
|
803
|
+
None, lambda: _fetch_report_data_sync(symbol)
|
|
804
|
+
)
|
|
805
|
+
except Exception as e:
|
|
806
|
+
logger.error("[report] data fetch failed: %s", e)
|
|
807
|
+
df = _pd.DataFrame()
|
|
808
|
+
clean_result = None
|
|
809
|
+
import re as _re
|
|
810
|
+
_is_ashare = bool(_re.match(r"^[036]\d{5}$", symbol))
|
|
811
|
+
fundamentals = {"company_name": symbol, "symbol": symbol,
|
|
812
|
+
"currency": "CNY" if _is_ashare else "USD"}
|
|
813
|
+
|
|
814
|
+
# Generate chart (CPU-bound, run in thread)
|
|
815
|
+
price_chart = None
|
|
816
|
+
if not df.empty:
|
|
817
|
+
try:
|
|
818
|
+
price_chart = await loop.run_in_executor(
|
|
819
|
+
None, lambda: generate_price_chart(df, symbol, fundamentals)
|
|
820
|
+
)
|
|
821
|
+
except Exception as e:
|
|
822
|
+
logger.debug("[report] chart failed: %s", e)
|
|
823
|
+
|
|
824
|
+
# Render
|
|
825
|
+
try:
|
|
826
|
+
report_html = _build_html(symbol, fundamentals, price_chart,
|
|
827
|
+
team_result, clean_result)
|
|
828
|
+
except Exception as e:
|
|
829
|
+
logger.error("[report] render failed: %s", e)
|
|
830
|
+
return None
|
|
831
|
+
|
|
832
|
+
ts = ts_dt.strftime("%Y%m%d_%H%M")
|
|
833
|
+
out_f = artifact.path if artifact else out_dir / f"{symbol}_report_{ts}.html"
|
|
834
|
+
out_f.write_text(report_html, encoding="utf-8")
|
|
835
|
+
|
|
836
|
+
if artifact:
|
|
837
|
+
missing_fields = [
|
|
838
|
+
key for key in ("price", "market_cap", "pe_ratio", "pb_ratio", "roe", "rsi", "macd")
|
|
839
|
+
if fundamentals.get(key) in (None, "", 0)
|
|
840
|
+
]
|
|
841
|
+
status = "complete" if not df.empty and fundamentals.get("price") else "data_unavailable"
|
|
842
|
+
if status == "complete" and missing_fields:
|
|
843
|
+
status = "partial"
|
|
844
|
+
provider_chain = fundamentals.get("data_provider_chain") or []
|
|
845
|
+
warnings = fundamentals.get("data_warnings") or []
|
|
846
|
+
data_quality = fundamentals.get("data_quality") or {}
|
|
847
|
+
write_artifact_metadata(artifact, {
|
|
848
|
+
"kind": "market_report",
|
|
849
|
+
"status": data_quality.get("status") or status,
|
|
850
|
+
"symbol": symbol,
|
|
851
|
+
"created_at": ts_dt.isoformat(timespec="seconds"),
|
|
852
|
+
"data": {
|
|
853
|
+
"provider_chain": provider_chain,
|
|
854
|
+
"warnings": warnings,
|
|
855
|
+
"errors": data_quality.get("errors") or [],
|
|
856
|
+
"stale": bool(data_quality.get("stale", False)),
|
|
857
|
+
"quality": data_quality,
|
|
858
|
+
"missing_fields": missing_fields,
|
|
859
|
+
"rows": int(len(df)) if df is not None else 0,
|
|
860
|
+
"quality_score": getattr(clean_result, "quality_score", None),
|
|
861
|
+
"chart_rendered": bool(price_chart),
|
|
862
|
+
},
|
|
863
|
+
"model": {
|
|
864
|
+
"team_result": bool(team_result),
|
|
865
|
+
},
|
|
866
|
+
})
|
|
867
|
+
raw_records = []
|
|
868
|
+
try:
|
|
869
|
+
raw_records = df.reset_index().tail(370).to_dict(orient="records") if df is not None and not df.empty else []
|
|
870
|
+
except Exception:
|
|
871
|
+
raw_records = []
|
|
872
|
+
write_artifact_raw_data(artifact, {
|
|
873
|
+
"symbol": symbol,
|
|
874
|
+
"fundamentals": fundamentals,
|
|
875
|
+
"prices": raw_records,
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
logger.info("[report] saved: %s", out_f)
|
|
879
|
+
return out_f
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# ── PDF Export ────────────────────────────────────────────────────────────────
|
|
883
|
+
|
|
884
|
+
def export_pdf(html_path: Path) -> Optional[Path]:
|
|
885
|
+
"""
|
|
886
|
+
Convert an HTML report to PDF alongside the source file.
|
|
887
|
+
Tries weasyprint (pure Python) first, then wkhtmltopdf binary.
|
|
888
|
+
Returns the PDF path on success, None if neither tool is available.
|
|
889
|
+
"""
|
|
890
|
+
pdf_path = html_path.with_suffix(".pdf")
|
|
891
|
+
|
|
892
|
+
try:
|
|
893
|
+
import weasyprint
|
|
894
|
+
weasyprint.HTML(filename=str(html_path)).write_pdf(str(pdf_path))
|
|
895
|
+
logger.info("[report] pdf via weasyprint: %s", pdf_path)
|
|
896
|
+
return pdf_path
|
|
897
|
+
except ImportError:
|
|
898
|
+
pass
|
|
899
|
+
except Exception as e:
|
|
900
|
+
logger.debug("[report] weasyprint failed: %s", e)
|
|
901
|
+
|
|
902
|
+
import shutil, subprocess as _sp
|
|
903
|
+
if shutil.which("wkhtmltopdf"):
|
|
904
|
+
try:
|
|
905
|
+
r = _sp.run(
|
|
906
|
+
["wkhtmltopdf", "--quiet", "--print-media-type",
|
|
907
|
+
str(html_path), str(pdf_path)],
|
|
908
|
+
capture_output=True, timeout=60,
|
|
909
|
+
)
|
|
910
|
+
if r.returncode == 0 and pdf_path.exists():
|
|
911
|
+
logger.info("[report] pdf via wkhtmltopdf: %s", pdf_path)
|
|
912
|
+
return pdf_path
|
|
913
|
+
except Exception as e:
|
|
914
|
+
logger.debug("[report] wkhtmltopdf failed: %s", e)
|
|
915
|
+
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
# ── Reports Index ─────────────────────────────────────────────────────────────
|
|
920
|
+
|
|
921
|
+
def update_reports_index(reports_root: Path) -> Optional[Path]:
|
|
922
|
+
"""
|
|
923
|
+
Scan reports_root recursively for *_report_*.html files and write index.html.
|
|
924
|
+
Returns the index path on success.
|
|
925
|
+
"""
|
|
926
|
+
import re as _ri
|
|
927
|
+
|
|
928
|
+
index_path = reports_root / "index.html"
|
|
929
|
+
entries: List[Dict] = []
|
|
930
|
+
|
|
931
|
+
for f in sorted(reports_root.rglob("*_report_*.html"), reverse=True):
|
|
932
|
+
if f.name == "index.html":
|
|
933
|
+
continue
|
|
934
|
+
m = _ri.match(r"^(.+?)_report_(\d{8})_(\d{4})\.html$", f.name)
|
|
935
|
+
if not m:
|
|
936
|
+
continue
|
|
937
|
+
sym, ds, ts_ = m.group(1), m.group(2), m.group(3)
|
|
938
|
+
dt_str = f"{ds[:4]}-{ds[4:6]}-{ds[6:8]} {ts_[:2]}:{ts_[2:]}"
|
|
939
|
+
size_kb = max(1, f.stat().st_size // 1024)
|
|
940
|
+
|
|
941
|
+
signal = "HOLD"
|
|
942
|
+
try:
|
|
943
|
+
snip = f.read_text(encoding="utf-8", errors="ignore")[500:3000]
|
|
944
|
+
sm = _ri.search(r'\b(STRONG_BUY|STRONG_SELL|BUY|SELL|HOLD)\b', snip)
|
|
945
|
+
if sm:
|
|
946
|
+
signal = sm.group(1)
|
|
947
|
+
except Exception:
|
|
948
|
+
pass
|
|
949
|
+
|
|
950
|
+
try:
|
|
951
|
+
rel = f.relative_to(reports_root)
|
|
952
|
+
except ValueError:
|
|
953
|
+
rel = f.name
|
|
954
|
+
entries.append({"symbol": sym.upper(), "datetime": dt_str,
|
|
955
|
+
"signal": signal, "size_kb": size_kb,
|
|
956
|
+
"href": str(rel).replace("\\", "/")})
|
|
957
|
+
|
|
958
|
+
_SIG_COLOR = {"STRONG_BUY": "#3fb950", "BUY": "#3fb950",
|
|
959
|
+
"HOLD": "#79c0ff", "SELL": "#f85149", "STRONG_SELL": "#f85149"}
|
|
960
|
+
|
|
961
|
+
rows_html = ""
|
|
962
|
+
for e in entries:
|
|
963
|
+
sc = _SIG_COLOR.get(e["signal"], "#8b949e")
|
|
964
|
+
rows_html += (
|
|
965
|
+
f'<tr>'
|
|
966
|
+
f'<td><a href="{html.escape(e["href"])}" class="sym">'
|
|
967
|
+
f'{html.escape(e["symbol"])}</a></td>'
|
|
968
|
+
f'<td style="color:{sc};font-weight:700">{e["signal"]}</td>'
|
|
969
|
+
f'<td class="dim">{html.escape(e["datetime"])}</td>'
|
|
970
|
+
f'<td class="dim">{e["size_kb"]}KB</td>'
|
|
971
|
+
f'</tr>\n'
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
idx_html = f"""<!DOCTYPE html>
|
|
975
|
+
<html lang="zh-CN">
|
|
976
|
+
<head>
|
|
977
|
+
<meta charset="utf-8">
|
|
978
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
979
|
+
<title>Aria Code — 研报索引</title>
|
|
980
|
+
<style>
|
|
981
|
+
*{{box-sizing:border-box;margin:0;padding:0}}
|
|
982
|
+
body{{background:#010409;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
983
|
+
font-size:14px;padding:28px 24px;max-width:900px;margin:0 auto}}
|
|
984
|
+
h1{{font-size:22px;font-weight:700;margin-bottom:4px}}
|
|
985
|
+
.sub{{color:#8b949e;font-size:12px;margin-bottom:24px}}
|
|
986
|
+
table{{width:100%;border-collapse:collapse}}
|
|
987
|
+
th{{text-align:left;padding:8px 12px;border-bottom:2px solid #30363d;
|
|
988
|
+
color:#8b949e;font-size:11px;letter-spacing:.6px;text-transform:uppercase}}
|
|
989
|
+
td{{padding:9px 12px;border-bottom:1px solid #21262d}}
|
|
990
|
+
tr:hover td{{background:#161b22}}
|
|
991
|
+
a.sym{{color:#58a6ff;font-weight:700;text-decoration:none;font-size:15px}}
|
|
992
|
+
a.sym:hover{{text-decoration:underline}}
|
|
993
|
+
.dim{{color:#8b949e;font-size:12px}}
|
|
994
|
+
</style>
|
|
995
|
+
</head>
|
|
996
|
+
<body>
|
|
997
|
+
<h1>Aria Code 研报索引</h1>
|
|
998
|
+
<p class="sub">{len(entries)} 份报告 · 更新于 {datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
|
|
999
|
+
<table>
|
|
1000
|
+
<thead><tr><th>标的</th><th>信号</th><th>生成时间</th><th>大小</th></tr></thead>
|
|
1001
|
+
<tbody>
|
|
1002
|
+
{rows_html}
|
|
1003
|
+
</tbody>
|
|
1004
|
+
</table>
|
|
1005
|
+
</body>
|
|
1006
|
+
</html>"""
|
|
1007
|
+
|
|
1008
|
+
index_path.write_text(idx_html, encoding="utf-8")
|
|
1009
|
+
logger.info("[report] index updated: %s (%d reports)", index_path, len(entries))
|
|
1010
|
+
return index_path
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
# ── HTML Template & CSS ───────────────────────────────────────────────────────
|
|
1014
|
+
|
|
1015
|
+
_CSS = """
|
|
1016
|
+
:root {
|
|
1017
|
+
--bg0: #010409;
|
|
1018
|
+
--bg1: #0d1117;
|
|
1019
|
+
--bg2: #161b22;
|
|
1020
|
+
--bg3: #21262d;
|
|
1021
|
+
--text1: #e6edf3;
|
|
1022
|
+
--text2: #8b949e;
|
|
1023
|
+
--green: #3fb950;
|
|
1024
|
+
--red: #f85149;
|
|
1025
|
+
--blue: #388bfd;
|
|
1026
|
+
--purple: #8957e5;
|
|
1027
|
+
--orange: #f0883e;
|
|
1028
|
+
--border: #21262d;
|
|
1029
|
+
--radius: 8px;
|
|
1030
|
+
}
|
|
1031
|
+
* { box-sizing:border-box; margin:0; padding:0; }
|
|
1032
|
+
body {
|
|
1033
|
+
background: var(--bg0);
|
|
1034
|
+
color: var(--text1);
|
|
1035
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif;
|
|
1036
|
+
font-size: 14px;
|
|
1037
|
+
line-height: 1.6;
|
|
1038
|
+
padding: 28px 24px;
|
|
1039
|
+
max-width: 1180px;
|
|
1040
|
+
margin: 0 auto;
|
|
1041
|
+
}
|
|
1042
|
+
/* ── Header ── */
|
|
1043
|
+
.report-header {
|
|
1044
|
+
display: flex;
|
|
1045
|
+
align-items: flex-start;
|
|
1046
|
+
justify-content: space-between;
|
|
1047
|
+
border-bottom: 1px solid var(--border);
|
|
1048
|
+
padding-bottom: 18px;
|
|
1049
|
+
margin-bottom: 20px;
|
|
1050
|
+
flex-wrap: wrap;
|
|
1051
|
+
gap: 12px;
|
|
1052
|
+
}
|
|
1053
|
+
.header-left h1 {
|
|
1054
|
+
font-size: 26px;
|
|
1055
|
+
font-weight: 700;
|
|
1056
|
+
letter-spacing: -0.5px;
|
|
1057
|
+
color: var(--text1);
|
|
1058
|
+
}
|
|
1059
|
+
.header-left h1 .ticker {
|
|
1060
|
+
color: var(--green);
|
|
1061
|
+
margin-right: 10px;
|
|
1062
|
+
font-size: 28px;
|
|
1063
|
+
}
|
|
1064
|
+
.header-left .subtitle {
|
|
1065
|
+
color: var(--text2);
|
|
1066
|
+
font-size: 12px;
|
|
1067
|
+
margin-top: 4px;
|
|
1068
|
+
}
|
|
1069
|
+
.signal-badge {
|
|
1070
|
+
display: inline-flex;
|
|
1071
|
+
align-items: center;
|
|
1072
|
+
gap: 8px;
|
|
1073
|
+
padding: 10px 20px;
|
|
1074
|
+
border-radius: var(--radius);
|
|
1075
|
+
font-size: 18px;
|
|
1076
|
+
font-weight: 700;
|
|
1077
|
+
letter-spacing: 1px;
|
|
1078
|
+
border: 1px solid;
|
|
1079
|
+
}
|
|
1080
|
+
.report-meta {
|
|
1081
|
+
color: var(--text2);
|
|
1082
|
+
font-size: 12px;
|
|
1083
|
+
text-align: right;
|
|
1084
|
+
}
|
|
1085
|
+
/* ── KPI strip ── */
|
|
1086
|
+
.kpi-strip {
|
|
1087
|
+
display: flex;
|
|
1088
|
+
gap: 10px;
|
|
1089
|
+
flex-wrap: wrap;
|
|
1090
|
+
margin-bottom: 20px;
|
|
1091
|
+
}
|
|
1092
|
+
.kpi-card {
|
|
1093
|
+
background: var(--bg2);
|
|
1094
|
+
border: 1px solid var(--border);
|
|
1095
|
+
border-radius: var(--radius);
|
|
1096
|
+
padding: 12px 16px;
|
|
1097
|
+
flex: 1;
|
|
1098
|
+
min-width: 130px;
|
|
1099
|
+
}
|
|
1100
|
+
.kpi-label { font-size:11px; color:var(--text2); margin-bottom:4px; }
|
|
1101
|
+
.kpi-value { font-size:18px; font-weight:700; color:var(--text1); }
|
|
1102
|
+
.kpi-sub { font-size:11px; color:var(--text2); margin-top:3px; }
|
|
1103
|
+
/* ── Cards ── */
|
|
1104
|
+
.card {
|
|
1105
|
+
background: var(--bg1);
|
|
1106
|
+
border: 1px solid var(--border);
|
|
1107
|
+
border-radius: var(--radius);
|
|
1108
|
+
margin-bottom: 16px;
|
|
1109
|
+
overflow: hidden;
|
|
1110
|
+
}
|
|
1111
|
+
.card-header {
|
|
1112
|
+
background: var(--bg2);
|
|
1113
|
+
border-bottom: 1px solid var(--border);
|
|
1114
|
+
padding: 10px 16px;
|
|
1115
|
+
font-size: 12px;
|
|
1116
|
+
font-weight: 600;
|
|
1117
|
+
letter-spacing: 0.8px;
|
|
1118
|
+
text-transform: uppercase;
|
|
1119
|
+
color: var(--text2);
|
|
1120
|
+
}
|
|
1121
|
+
.card-body {
|
|
1122
|
+
padding: 16px;
|
|
1123
|
+
}
|
|
1124
|
+
/* ── Chart ── */
|
|
1125
|
+
.chart-card {
|
|
1126
|
+
background: var(--bg1);
|
|
1127
|
+
border: 1px solid var(--border);
|
|
1128
|
+
border-radius: var(--radius);
|
|
1129
|
+
padding: 4px;
|
|
1130
|
+
margin-bottom: 16px;
|
|
1131
|
+
}
|
|
1132
|
+
.no-data {
|
|
1133
|
+
color: var(--text2);
|
|
1134
|
+
font-style: italic;
|
|
1135
|
+
padding: 20px;
|
|
1136
|
+
text-align: center;
|
|
1137
|
+
}
|
|
1138
|
+
/* ── Two-column layout ── */
|
|
1139
|
+
.two-col {
|
|
1140
|
+
display: grid;
|
|
1141
|
+
grid-template-columns: 1fr 1fr;
|
|
1142
|
+
gap: 16px;
|
|
1143
|
+
margin-bottom: 16px;
|
|
1144
|
+
}
|
|
1145
|
+
@media (max-width: 700px) { .two-col { grid-template-columns: 1fr; } }
|
|
1146
|
+
/* ── Metrics table ── */
|
|
1147
|
+
.metrics-table {
|
|
1148
|
+
width: 100%;
|
|
1149
|
+
border-collapse: collapse;
|
|
1150
|
+
font-size: 13px;
|
|
1151
|
+
}
|
|
1152
|
+
.metrics-table td {
|
|
1153
|
+
padding: 6px 12px;
|
|
1154
|
+
border-bottom: 1px solid var(--bg3);
|
|
1155
|
+
}
|
|
1156
|
+
.metric-label { color: var(--text2); }
|
|
1157
|
+
.metric-value { color: var(--text1); text-align: right; font-weight: 500; }
|
|
1158
|
+
/* ── Agent cards ── */
|
|
1159
|
+
.agent-grid {
|
|
1160
|
+
display: grid;
|
|
1161
|
+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
1162
|
+
gap: 12px;
|
|
1163
|
+
padding: 14px;
|
|
1164
|
+
}
|
|
1165
|
+
.agent-card {
|
|
1166
|
+
background: var(--bg2);
|
|
1167
|
+
border: 1px solid;
|
|
1168
|
+
border-radius: var(--radius);
|
|
1169
|
+
padding: 12px;
|
|
1170
|
+
}
|
|
1171
|
+
.agent-header {
|
|
1172
|
+
display: flex;
|
|
1173
|
+
align-items: center;
|
|
1174
|
+
gap: 10px;
|
|
1175
|
+
margin-bottom: 8px;
|
|
1176
|
+
}
|
|
1177
|
+
.agent-name { font-weight:700; font-size:12px; color:var(--text2);
|
|
1178
|
+
letter-spacing:0.8px; text-transform:uppercase; }
|
|
1179
|
+
.agent-signal { font-size:12px; font-weight:700; padding:2px 8px;
|
|
1180
|
+
border-radius:4px; }
|
|
1181
|
+
.agent-conf { font-size:12px; font-weight:600; margin-left:auto; }
|
|
1182
|
+
.agent-kps { padding-left:16px; margin-bottom:8px; }
|
|
1183
|
+
.agent-kps li { font-size:12px; color:var(--text2); margin-bottom:3px; }
|
|
1184
|
+
.agent-analysis { font-size:12px; color:var(--text2);
|
|
1185
|
+
border-top:1px solid var(--bg3); padding-top:8px;
|
|
1186
|
+
margin-top:4px; line-height:1.65; }
|
|
1187
|
+
.inline-chart { width:100%; height:auto; border-radius:6px; display:block; }
|
|
1188
|
+
/* ── Synthesis ── */
|
|
1189
|
+
.synthesis-body {
|
|
1190
|
+
font-size: 14px;
|
|
1191
|
+
line-height: 1.75;
|
|
1192
|
+
color: var(--text1);
|
|
1193
|
+
}
|
|
1194
|
+
/* ── Markdown elements ── */
|
|
1195
|
+
.md-table { width:100%; border-collapse:collapse; font-size:12px;
|
|
1196
|
+
margin:8px 0; border:1px solid var(--bg3); }
|
|
1197
|
+
.md-table th { background:var(--bg3); color:var(--text2); padding:5px 8px;
|
|
1198
|
+
text-align:left; font-weight:600; border:1px solid var(--border); }
|
|
1199
|
+
.md-table td { padding:4px 8px; border:1px solid var(--bg3); color:var(--text2); }
|
|
1200
|
+
.md-table tr:nth-child(even) { background:var(--bg2); }
|
|
1201
|
+
.md-h { display:block; color:var(--text1); margin:8px 0 4px; font-size:13px; }
|
|
1202
|
+
.md-hr { border:none; border-top:1px solid var(--border); margin:8px 0; }
|
|
1203
|
+
code { background:var(--bg3); color:var(--orange); padding:1px 4px;
|
|
1204
|
+
border-radius:3px; font-size:11px; font-family:monospace; }
|
|
1205
|
+
.synthesis-footer {
|
|
1206
|
+
border-top: 1px solid var(--border);
|
|
1207
|
+
padding: 10px 16px;
|
|
1208
|
+
font-size: 12px;
|
|
1209
|
+
color: var(--text2);
|
|
1210
|
+
display: flex;
|
|
1211
|
+
gap: 16px;
|
|
1212
|
+
align-items: center;
|
|
1213
|
+
}
|
|
1214
|
+
/* ── Quality bar ── */
|
|
1215
|
+
.quality-bar {
|
|
1216
|
+
display: flex;
|
|
1217
|
+
align-items: center;
|
|
1218
|
+
gap: 14px;
|
|
1219
|
+
background: var(--bg2);
|
|
1220
|
+
border: 1px solid var(--border);
|
|
1221
|
+
border-radius: var(--radius);
|
|
1222
|
+
padding: 10px 16px;
|
|
1223
|
+
font-size: 12px;
|
|
1224
|
+
color: var(--text2);
|
|
1225
|
+
margin-bottom: 16px;
|
|
1226
|
+
}
|
|
1227
|
+
.quality-score { font-size: 18px; font-weight: 700; }
|
|
1228
|
+
.quality-detail { color: var(--text2); font-size: 11px; }
|
|
1229
|
+
/* ── Footer ── */
|
|
1230
|
+
.report-footer {
|
|
1231
|
+
margin-top: 28px;
|
|
1232
|
+
padding-top: 14px;
|
|
1233
|
+
border-top: 1px solid var(--border);
|
|
1234
|
+
font-size: 11px;
|
|
1235
|
+
color: var(--text2);
|
|
1236
|
+
line-height: 1.8;
|
|
1237
|
+
}
|
|
1238
|
+
@media print {
|
|
1239
|
+
body { background:#fff; color:#000; padding:16px; }
|
|
1240
|
+
.signal-badge, .agent-card { border-color:#ccc !important; }
|
|
1241
|
+
.card, .kpi-card { background:#f8f8f8 !important; border-color:#ddd; }
|
|
1242
|
+
}
|
|
1243
|
+
"""
|
|
1244
|
+
|
|
1245
|
+
_HTML_TEMPLATE = """<!DOCTYPE html>
|
|
1246
|
+
<html lang="zh-CN">
|
|
1247
|
+
<head>
|
|
1248
|
+
<meta charset="utf-8">
|
|
1249
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1250
|
+
<title>{{SYMBOL}} — Aria Research Report</title>
|
|
1251
|
+
<style>{{CSS}}</style>
|
|
1252
|
+
</head>
|
|
1253
|
+
<body>
|
|
1254
|
+
|
|
1255
|
+
<!-- Header -->
|
|
1256
|
+
<div class="report-header">
|
|
1257
|
+
<div class="header-left">
|
|
1258
|
+
<h1><span class="ticker">{{SYMBOL}}</span>{{COMPANY_NAME}}</h1>
|
|
1259
|
+
<div class="subtitle">{{SUBTITLE}}</div>
|
|
1260
|
+
</div>
|
|
1261
|
+
<div>
|
|
1262
|
+
<div class="signal-badge"
|
|
1263
|
+
style="background:{{SIGNAL_BG}};color:{{SIGNAL_ACCENT}};border-color:{{SIGNAL_ACCENT}}40;">
|
|
1264
|
+
{{SIGNAL_ICON}} {{SIGNAL}} {{CONFIDENCE}}
|
|
1265
|
+
</div>
|
|
1266
|
+
<div class="report-meta" style="margin-top:8px;">
|
|
1267
|
+
Aria Code Research<br>{{TIMESTAMP}}
|
|
1268
|
+
</div>
|
|
1269
|
+
</div>
|
|
1270
|
+
</div>
|
|
1271
|
+
|
|
1272
|
+
<!-- KPI Strip -->
|
|
1273
|
+
<div class="kpi-strip">
|
|
1274
|
+
{{KPI_CARDS}}
|
|
1275
|
+
</div>
|
|
1276
|
+
|
|
1277
|
+
<!-- Quality Bar -->
|
|
1278
|
+
{{QUALITY_BAR}}
|
|
1279
|
+
|
|
1280
|
+
<!-- Price Chart -->
|
|
1281
|
+
<div class="chart-card">
|
|
1282
|
+
{{CHART_SECTION}}
|
|
1283
|
+
</div>
|
|
1284
|
+
|
|
1285
|
+
<!-- Two-column: Agent analysis + Metrics table -->
|
|
1286
|
+
<div class="two-col">
|
|
1287
|
+
<div>
|
|
1288
|
+
{{AGENT_SECTION}}
|
|
1289
|
+
</div>
|
|
1290
|
+
<div>
|
|
1291
|
+
<section class="card">
|
|
1292
|
+
<div class="card-header">关键财务指标</div>
|
|
1293
|
+
<table class="metrics-table">
|
|
1294
|
+
<tbody>
|
|
1295
|
+
{{METRICS_ROWS}}
|
|
1296
|
+
</tbody>
|
|
1297
|
+
</table>
|
|
1298
|
+
</section>
|
|
1299
|
+
{{DESC_SECTION}}
|
|
1300
|
+
</div>
|
|
1301
|
+
</div>
|
|
1302
|
+
|
|
1303
|
+
<!-- Synthesis -->
|
|
1304
|
+
{{SYNTHESIS}}
|
|
1305
|
+
|
|
1306
|
+
<!-- Footer -->
|
|
1307
|
+
<div class="report-footer">
|
|
1308
|
+
<strong>免责声明</strong>:本报告由 Aria Code AI 系统自动生成,仅供参考,不构成任何投资建议或买卖推荐。
|
|
1309
|
+
数据源:{{DATA_SOURCE}}。存在延迟,请以交易所官方数据为准。
|
|
1310
|
+
投资有风险,入市需谨慎。 · Aria Code · {{TIMESTAMP}}
|
|
1311
|
+
</div>
|
|
1312
|
+
|
|
1313
|
+
</body>
|
|
1314
|
+
</html>"""
|