mirror of
https://github.com/simonlin1212/TradingAgents-astock.git
synced 2026-10-30 09:29:56 +00:00
fix: 财报公布判断加入入库时间,逾期披露不再提前露出;三表工具 curr_date 必填
- 已公布 = 公告日早于分析日,或入库时间(北京时间)早于分析日当天;两个日期都晚于 报告期一年以上(原始日期已被改写丢失)时才按法定截止日判断。原来「截止日或公告日」 会让逾期披露的公司(300630 的 2023 年报 2024-07-05 才发)截止日一过就露出来。 - get_balance_sheet / get_cashflow / get_income_statement 的 curr_date 改为必填,提示词写明要传。 - 回看越早取的期数越多;按报告期从新到旧排序;同名科目不再互相覆盖;没有报告日列时不返回未筛的数据。 - 更正 start_analysis 生产者数量的注释(4 个,历史记录只清空)。
This commit is contained in:
@@ -276,3 +276,38 @@ def test_is_historical_uses_market_timezone_not_host(monkeypatch):
|
||||
# 市场当天不该被判成历史,哪怕主机日历已经翻页
|
||||
assert a_stock._is_historical("2026-08-09") is False
|
||||
assert a_stock._is_historical("2026-08-08") is True
|
||||
|
||||
|
||||
def test_statement_tools_require_curr_date():
|
||||
"""三张财报表按分析日筛已公布的报表;curr_date 可选时模型漏传就整段不筛(v0.5.21 复核指出)。"""
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_balance_sheet,
|
||||
get_cashflow,
|
||||
get_income_statement,
|
||||
)
|
||||
|
||||
for tool in (get_balance_sheet, get_cashflow, get_income_statement):
|
||||
required = tool.args_schema.model_json_schema().get("required", [])
|
||||
assert "curr_date" in required, tool.name
|
||||
|
||||
|
||||
def test_statement_tools_forward_curr_date():
|
||||
import inspect
|
||||
|
||||
from tradingagents.agents.utils import fundamental_data_tools
|
||||
|
||||
src = inspect.getsource(fundamental_data_tools)
|
||||
for name in ("get_balance_sheet", "get_cashflow", "get_income_statement"):
|
||||
call = f'route_to_vendor("{name}", ticker, freq, curr_date)'
|
||||
assert call in src, f"{name} 没有把 curr_date 转发给数据层"
|
||||
|
||||
|
||||
def test_fundamentals_prompt_asks_for_date_on_statement_tools():
|
||||
import inspect
|
||||
|
||||
from tradingagents.agents.analysts import fundamentals_analyst
|
||||
|
||||
src = inspect.getsource(fundamentals_analyst.create_fundamentals_analyst)
|
||||
for name in ("get_balance_sheet", "get_cashflow", "get_income_statement"):
|
||||
assert f"{name}(ticker, curr_date)" in src, name
|
||||
assert "三张财报表的 **curr_date 也必须传" in src
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Regression tests for the Sina financial-statement response shape.
|
||||
|
||||
Sina moved the payload from ``result.data.<fzb|lrb|llb>`` (a flat list of rows)
|
||||
to ``result.data.report_list{报告期: {..., data: [{item_title, item_value}]}}``.
|
||||
|
||||
The old parser only read the former, so all three statements silently came back
|
||||
as "No ... data found" instead of raising — the agent then reported "this stock
|
||||
has no financial statements" as a fact.
|
||||
The API returns ``result.data.report_list{报告期: {..., data: [{item_title, item_value}]}}``.
|
||||
The parser written in v0.2.5 only read ``result.data.<fzb|lrb|llb>`` (a flat list of
|
||||
rows), so all three statements silently came back as "No ... data found" instead of
|
||||
raising — the agent then reported "this stock has no financial statements" as a fact.
|
||||
"""
|
||||
|
||||
import copy
|
||||
@@ -16,7 +14,7 @@ import pytest
|
||||
|
||||
from tradingagents.dataflows import a_stock
|
||||
|
||||
# Shape returned by the API since ~2026-10.
|
||||
# Shape the live API returns.
|
||||
NEW_SHAPE = {
|
||||
"result": {
|
||||
"status": {"code": 0},
|
||||
@@ -61,7 +59,7 @@ NEW_SHAPE = {
|
||||
}
|
||||
}
|
||||
|
||||
# Shape the parser was originally written against.
|
||||
# Flat-list shape the v0.2.5 parser was written against; kept for compatibility.
|
||||
LEGACY_SHAPE = {
|
||||
"result": {
|
||||
"data": {
|
||||
@@ -172,15 +170,27 @@ def test_report_not_yet_published_on_analysis_date_is_excluded(mock_get):
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_report_published_on_or_before_analysis_date_is_kept(mock_get):
|
||||
def test_report_published_before_analysis_date_is_kept(mock_get):
|
||||
mock_get.return_value = _response(_with_publish_dates("20260815", "20260425"))
|
||||
|
||||
for curr_date in ("2026-08-15", "2026-09-30"):
|
||||
for curr_date in ("2026-08-16", "2026-09-30"):
|
||||
out = a_stock.get_balance_sheet("600519", "quarterly", curr_date)
|
||||
assert "53518798979.08" in out, (curr_date, out)
|
||||
assert "50000000000.0" in out, (curr_date, out)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_publish_day_itself_is_not_enough(mock_get):
|
||||
"""Reports often go out after the close on their publish_date; the analysis day must be later."""
|
||||
mock_get.return_value = _response(_with_publish_dates("20260815", "20260425"))
|
||||
|
||||
out = a_stock.get_balance_sheet("600519", "quarterly", "2026-08-15")
|
||||
|
||||
assert "53518798979.08" not in out
|
||||
assert "50000000000.0" in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_late_republish_date_does_not_hide_report_past_deadline(mock_get):
|
||||
@@ -250,3 +260,182 @@ def test_deadline_day_itself_is_not_enough(mock_get):
|
||||
|
||||
assert "53518798979.08" not in a_stock.get_balance_sheet("600519", "quarterly", "2026-08-31")
|
||||
assert "53518798979.08" in a_stock.get_balance_sheet("600519", "quarterly", "2026-09-01")
|
||||
|
||||
|
||||
def _ts(beijing_time):
|
||||
"""Unix seconds as Sina's update_time, from a Beijing wall-clock time."""
|
||||
return str(int(pd.Timestamp(beijing_time, tz="Asia/Shanghai").timestamp()))
|
||||
|
||||
|
||||
def _periods(**periods):
|
||||
"""report_list with one 货币资金 row per period: name -> (value, publish_date, update_time)."""
|
||||
report_list = {}
|
||||
for name, (value, published, updated) in periods.items():
|
||||
payload = {"data": [{"item_field": "CURFDS", "item_title": "货币资金", "item_value": value}]}
|
||||
if published:
|
||||
payload["publish_date"] = published
|
||||
if updated:
|
||||
payload["update_time"] = updated
|
||||
report_list[name[1:]] = payload
|
||||
return {"result": {"data": {"report_list": report_list}}}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_late_filer_is_not_shown_once_the_deadline_passes(mock_get):
|
||||
"""A company that files its annual report in July must not appear published on 04-30.
|
||||
|
||||
Real case: 300630's 2023 annual and 2024 Q1 reports both came out on 2024-07-05.
|
||||
"""
|
||||
mock_get.return_value = _response(_periods(
|
||||
d20251231=("111.0", "20260705", _ts("2026-07-05 23:42")),
|
||||
d20250930=("222.0", "20251030", _ts("2025-10-29 18:12")),
|
||||
))
|
||||
|
||||
for curr_date in ("2026-05-15", "2026-07-05"):
|
||||
out = a_stock.get_balance_sheet("600519", "quarterly", curr_date)
|
||||
assert "111.0" not in out, (curr_date, out)
|
||||
assert "222.0" in out, (curr_date, out)
|
||||
assert "111.0" in a_stock.get_balance_sheet("600519", "quarterly", "2026-07-06")
|
||||
|
||||
# Same late filer with no update_time: its publish_date alone is still the original date.
|
||||
mock_get.return_value = _response(_periods(d20251231=("111.0", "20260705", None)))
|
||||
assert "111.0" not in a_stock.get_balance_sheet("600519", "quarterly", "2026-05-15")
|
||||
assert "111.0" in a_stock.get_balance_sheet("600519", "quarterly", "2026-07-06")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_update_time_is_read_in_beijing_time(mock_get):
|
||||
"""07:30 Beijing is still the previous day in UTC; it must not count as entered the day before."""
|
||||
mock_get.return_value = _response(_periods(
|
||||
d20251231=("111.0", "20270420", _ts("2026-07-06 07:30")),
|
||||
))
|
||||
|
||||
assert "111.0" not in a_stock.get_income_statement("600519", "quarterly", "2026-07-06")
|
||||
assert "111.0" in a_stock.get_income_statement("600519", "quarterly", "2026-07-07")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_update_time_admits_report_whose_publish_date_was_rewritten(mock_get):
|
||||
"""600519's 2025 year-end balance sheet: published 2026-04-17, but publish_date now says
|
||||
2026-08-15 (re-printed in the half-year report as the opening balance). update_time still
|
||||
holds the original evening entry and must let it through from the next day."""
|
||||
mock_get.return_value = _response(_periods(
|
||||
d20251231=("111.0", "20260815", _ts("2026-04-16 22:54")),
|
||||
))
|
||||
|
||||
assert "111.0" not in a_stock.get_balance_sheet("600519", "quarterly", "2026-04-16")
|
||||
for curr_date in ("2026-04-17", "2026-05-15"):
|
||||
assert "111.0" in a_stock.get_balance_sheet("600519", "quarterly", curr_date), curr_date
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_deadline_applies_only_when_both_dates_were_rewritten(mock_get):
|
||||
"""Both dates more than a year after the period: the original date is lost, so assume on time.
|
||||
|
||||
600519's 2021-09-30 income statement: publish_date 2022-10-17, update_time 2022-10-16.
|
||||
"""
|
||||
mock_get.return_value = _response(_periods(
|
||||
d20210930=("111.0", "20221017", _ts("2022-10-16 15:57")),
|
||||
))
|
||||
|
||||
assert "111.0" not in a_stock.get_income_statement("600519", "quarterly", "2021-10-31")
|
||||
assert "111.0" in a_stock.get_income_statement("600519", "quarterly", "2021-11-01")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_late_filer_with_rewritten_publish_date_still_waits_for_update_time(mock_get):
|
||||
"""Income statement re-printed in next year's annual: publish_date moves a year on, but
|
||||
update_time keeps the late July entry, so the original date is not lost."""
|
||||
mock_get.return_value = _response(_periods(
|
||||
d20251231=("111.0", "20270420", _ts("2026-07-05 23:42")),
|
||||
))
|
||||
|
||||
assert "111.0" not in a_stock.get_income_statement("600519", "quarterly", "2026-05-15")
|
||||
assert "111.0" in a_stock.get_income_statement("600519", "quarterly", "2026-07-06")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_internal_date_columns_are_not_returned(mock_get):
|
||||
mock_get.return_value = _response(_periods(
|
||||
d20251231=("111.0", "20260417", _ts("2026-04-16 22:54")),
|
||||
))
|
||||
|
||||
for curr_date in (None, "2026-09-30"):
|
||||
out = a_stock.get_balance_sheet("600519", "quarterly", curr_date)
|
||||
assert "111.0" in out, out
|
||||
assert "公告日" not in out and "入库时间" not in out, out
|
||||
assert "20260417" not in out, out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_duplicate_item_titles_do_not_overwrite_each_other(mock_get):
|
||||
"""利润表 has 利息收入 under 营业总收入 and again under 财务费用; both values must survive."""
|
||||
payload = {"result": {"data": {"report_list": {"20260630": {
|
||||
"publish_date": "20260815",
|
||||
"data": [
|
||||
{"item_field": "BIZTOTINCO", "item_title": "营业总收入", "item_display": "大类", "item_value": "92278072083.21"},
|
||||
{"item_field": "INTEINCO", "item_title": "利息收入", "item_display": "小类", "item_value": "1574811118.73"},
|
||||
{"item_field": "FINEXPE", "item_title": "财务费用", "item_display": "小类", "item_value": "-243237716.96"},
|
||||
{"item_field": "INTEINCOOPCOST", "item_title": "利息收入", "item_display": "缩进", "item_value": "266574096.14"},
|
||||
{"item_field": "OTHERA", "item_title": "其他", "item_display": "小类", "item_value": "11.0"},
|
||||
{"item_field": "OTHERB", "item_title": "其他", "item_display": "小类", "item_value": "22.0"},
|
||||
],
|
||||
}}}}}
|
||||
mock_get.return_value = _response(payload)
|
||||
|
||||
df = a_stock._get_financial_report_sina("600519", "利润表", "quarterly", "2026-09-30")
|
||||
|
||||
assert df.loc[df.index[0], "利息收入"] == "1574811118.73"
|
||||
assert df.loc[df.index[0], "财务费用-利息收入"] == "266574096.14"
|
||||
assert df.loc[df.index[0], "其他"] == "11.0"
|
||||
assert df.loc[df.index[0], "其他(OTHERB)"] == "22.0"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_newest_published_periods_come_first(mock_get):
|
||||
"""Output order must not depend on the order the API happens to list periods in."""
|
||||
periods = {}
|
||||
for i, period in enumerate(pd.period_range("2023Q1", "2025Q4", freq="Q")):
|
||||
end = period.end_time.strftime("%Y%m%d")
|
||||
periods["d" + end] = (f"{1000 + i}.0", None, None)
|
||||
mock_get.return_value = _response(_periods(**periods)) # oldest first
|
||||
|
||||
df = a_stock._get_financial_report_sina("600519", "资产负债表", "quarterly", "2026-09-30")
|
||||
|
||||
assert list(df["货币资金"]) == [f"{1000 + i}.0" for i in range(11, 3, -1)]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_missing_report_date_column_is_not_returned_unfiltered(mock_get):
|
||||
"""Without 报告日 there is no way to tell what was published by the analysis date."""
|
||||
mock_get.return_value = _response({"result": {"data": {"report_list": [{"货币资金": "111.0"}]}}})
|
||||
|
||||
assert "No balance sheet data found" in a_stock.get_balance_sheet("600519", "quarterly", "2026-09-30")
|
||||
assert "111.0" in a_stock.get_balance_sheet("600519", "quarterly", None)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("tradingagents.dataflows.a_stock._requests.get")
|
||||
def test_older_analysis_dates_fetch_more_periods(mock_get):
|
||||
"""The API lists newest first; 20 periods only reach back ~5 years."""
|
||||
mock_get.return_value = _response(NEW_SHAPE)
|
||||
today = pd.Timestamp.now()
|
||||
|
||||
def num_for(curr_date):
|
||||
a_stock.get_balance_sheet("600519", "quarterly", curr_date)
|
||||
return int(mock_get.call_args.kwargs["params"]["num"])
|
||||
|
||||
assert num_for(None) == 20
|
||||
assert num_for(today.strftime("%Y-%m-%d")) == 20
|
||||
ten_years_ago = num_for((today - pd.DateOffset(years=10)).strftime("%Y-%m-%d"))
|
||||
assert ten_years_ago >= 20 + 40 - 1, ten_years_ago
|
||||
assert num_for("1990-01-01") == 120
|
||||
|
||||
@@ -149,8 +149,8 @@ def test_validation_of_explicit_config_passes():
|
||||
def test_app_guards_at_the_consumption_point():
|
||||
"""结构性回归:模型校验必须挂在 `start_analysis` 的**消费点**。
|
||||
|
||||
`start_analysis` 有 5 个生产者(侧栏开始分析 / 未完成任务续跑 / 历史记录 /
|
||||
报告页重新分析 / 错误页继续未完成任务)。只在侧栏按钮里守一个是不够的——实测
|
||||
`start_analysis` 有 4 个生产者(侧栏开始分析 / 未完成任务续跑 /
|
||||
报告页重新分析 / 错误页继续未完成任务;历史记录只把它清空)。只在侧栏按钮里守一个是不够的——实测
|
||||
漏过:深模型选「Custom model ID」且留空、走续跑路径,请求带着空模型打了出去,
|
||||
收到 `The supported API model names are ..., but you passed .`。
|
||||
|
||||
|
||||
@@ -38,9 +38,10 @@ def create_fundamentals_analyst(llm):
|
||||
"\n\n请使用以下工具获取数据:"
|
||||
"\n- `get_fundamentals`:获取公司综合基本面信息(PE/PB/总市值/季报财务快照/一致预期EPS/前向PE/PEG等)"
|
||||
"\n- `get_profit_forecast(ticker, curr_date)`:获取机构一致预期EPS详情(覆盖机构数、EPS区间、前向PE、PEG、PE消化时间)。**curr_date 必须传当前分析日期**——一致预期没有历史版本,数据层靠它判断是否在复盘历史并给出提示"
|
||||
"\n- `get_balance_sheet`:资产负债表详细数据"
|
||||
"\n- `get_cashflow`:现金流量表详细数据"
|
||||
"\n- `get_income_statement`:利润表详细数据"
|
||||
"\n- `get_balance_sheet(ticker, curr_date)`:资产负债表详细数据"
|
||||
"\n- `get_cashflow(ticker, curr_date)`:现金流量表详细数据"
|
||||
"\n- `get_income_statement(ticker, curr_date)`:利润表详细数据"
|
||||
"\n 三张财报表的 **curr_date 也必须传当前分析日期**——数据层只返回分析日之前已公布的财报,不传就会拿到之后才公布的报表"
|
||||
"\n- `get_industry_comparison(ticker, curr_date)`:获取全行业横向对比(90个行业涨跌幅/成交额/净流入排名,用于估值对标和行业定位)"
|
||||
"\n\n撰写详尽的基本面研究报告,给出具体数据支撑的分析结论(仅供研究参考,不构成投资建议)。报告末尾附 Markdown 表格汇总关键财务指标和估值水平。"
|
||||
"\n\n📋 必采清单 — 以下数据点必须出现在报告中,无法获取时标注 [数据缺失: xxx]:"
|
||||
|
||||
@@ -23,16 +23,17 @@ def get_fundamentals(
|
||||
@tool
|
||||
def get_balance_sheet(
|
||||
ticker: Annotated[str, "6-digit A-stock code (e.g. 600379). Must be numeric, NOT company name"],
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd (required)"],
|
||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve balance sheet data for a given ticker symbol.
|
||||
Uses the configured fundamental_data vendor.
|
||||
Args:
|
||||
ticker (str): Ticker symbol of the company
|
||||
curr_date (str): Current date you are trading at, yyyy-mm-dd. Only reports
|
||||
already published by this date are returned.
|
||||
freq (str): Reporting frequency: annual/quarterly (default quarterly)
|
||||
curr_date (str): Current date you are trading at, yyyy-mm-dd
|
||||
Returns:
|
||||
str: A formatted report containing balance sheet data
|
||||
"""
|
||||
@@ -42,16 +43,17 @@ def get_balance_sheet(
|
||||
@tool
|
||||
def get_cashflow(
|
||||
ticker: Annotated[str, "6-digit A-stock code (e.g. 600379). Must be numeric, NOT company name"],
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd (required)"],
|
||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve cash flow statement data for a given ticker symbol.
|
||||
Uses the configured fundamental_data vendor.
|
||||
Args:
|
||||
ticker (str): Ticker symbol of the company
|
||||
curr_date (str): Current date you are trading at, yyyy-mm-dd. Only reports
|
||||
already published by this date are returned.
|
||||
freq (str): Reporting frequency: annual/quarterly (default quarterly)
|
||||
curr_date (str): Current date you are trading at, yyyy-mm-dd
|
||||
Returns:
|
||||
str: A formatted report containing cash flow statement data
|
||||
"""
|
||||
@@ -61,16 +63,17 @@ def get_cashflow(
|
||||
@tool
|
||||
def get_income_statement(
|
||||
ticker: Annotated[str, "6-digit A-stock code (e.g. 600379). Must be numeric, NOT company name"],
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd (required)"],
|
||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve income statement data for a given ticker symbol.
|
||||
Uses the configured fundamental_data vendor.
|
||||
Args:
|
||||
ticker (str): Ticker symbol of the company
|
||||
curr_date (str): Current date you are trading at, yyyy-mm-dd. Only reports
|
||||
already published by this date are returned.
|
||||
freq (str): Reporting frequency: annual/quarterly (default quarterly)
|
||||
curr_date (str): Current date you are trading at, yyyy-mm-dd
|
||||
Returns:
|
||||
str: A formatted report containing income statement data
|
||||
"""
|
||||
|
||||
@@ -1163,6 +1163,10 @@ def _sina_stock_code(code: str) -> str:
|
||||
return f"{_get_prefix(code)}{code}"
|
||||
|
||||
|
||||
# 新浪每期报表附带的公告日 / 入库时间,只在内部判断是否已公布,不返回
|
||||
_SINA_PUBLISHED = "_公告日"
|
||||
_SINA_UPDATED = "_入库时间"
|
||||
|
||||
# 定期报告法定披露截止:一季报、三季报在报告期结束后 1 个月内,半年报 2 个月内,年报 4 个月内
|
||||
_REPORT_DEADLINE_MONTHS = {3: 1, 6: 2, 9: 1, 12: 4}
|
||||
|
||||
@@ -1176,6 +1180,17 @@ def _report_deadline(report_date: pd.Series) -> pd.Series:
|
||||
return deadline
|
||||
|
||||
|
||||
def _sina_report_num(curr_date: str = None) -> int:
|
||||
"""要取的期数:接口按最新在前返回,20 期只覆盖最近 5 年,回看越早要取得越多,否则过滤后是空表。"""
|
||||
if not curr_date:
|
||||
return 20
|
||||
try:
|
||||
days_back = (pd.Timestamp.now() - pd.to_datetime(curr_date)).days
|
||||
except (ValueError, TypeError):
|
||||
return 20
|
||||
return min(120, 20 + max(0, days_back) // 91)
|
||||
|
||||
|
||||
def _get_financial_report_sina(
|
||||
code: str, report_type: str, freq: str, curr_date: str = None,
|
||||
) -> pd.DataFrame:
|
||||
@@ -1198,7 +1213,7 @@ def _get_financial_report_sina(
|
||||
"source": source_type,
|
||||
"type": "0",
|
||||
"page": "1",
|
||||
"num": "20",
|
||||
"num": str(_sina_report_num(curr_date)),
|
||||
}
|
||||
r = _requests.get(url, params=params, headers={"User-Agent": _UA}, timeout=15)
|
||||
d = r.json()
|
||||
@@ -1206,10 +1221,10 @@ def _get_financial_report_sina(
|
||||
result = d.get("result", {}).get("data", {})
|
||||
items = result.get(source_type, [])
|
||||
|
||||
# 新浪把财报结果从 data.<fzb|lrb|llb>(行列表)改成了
|
||||
# data.report_list{报告期: {publish_date, ..., data: [{item_title, item_value}]}}。
|
||||
# 只认前者时三张财报表会静默返回空表("No balance sheet data found",#116)。
|
||||
# 这里同时兼容两种结构,上游再改版也不会退化成空表。
|
||||
# 这个接口的财报在 data.report_list{报告期: {publish_date, update_time, ...,
|
||||
# data: [{item_title, item_value}]}} 里;v0.2.5 起的解析只认 data.<fzb|lrb|llb>
|
||||
# 行列表,三张财报表因此一直静默返回空表("No balance sheet data found",#116)。
|
||||
# 行列表的写法保留兼容。
|
||||
if not isinstance(items, list) or not items:
|
||||
report_list = result.get("report_list")
|
||||
rows = []
|
||||
@@ -1218,13 +1233,23 @@ def _get_financial_report_sina(
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
row = {"报告日": report_date}
|
||||
parent = None
|
||||
for item in payload.get("data") or []:
|
||||
if isinstance(item, dict) and item.get("item_title"):
|
||||
row[item["item_title"]] = item.get("item_value")
|
||||
if not isinstance(item, dict) or not item.get("item_title"):
|
||||
continue
|
||||
title = item["item_title"]
|
||||
# 同名科目不能互相覆盖:利润表里营业总收入下、财务费用下各有一个「利息收入」
|
||||
if item.get("item_display") != "缩进":
|
||||
parent = title
|
||||
elif title in row and parent:
|
||||
title = f"{parent}-{title}"
|
||||
if title in row:
|
||||
title = f"{title}({item.get('item_field') or len(row)})"
|
||||
row[title] = item.get("item_value")
|
||||
if len(row) > 1:
|
||||
# 公告日只用于下面按分析日判断是否已公布,返回前去掉
|
||||
if payload.get("publish_date"):
|
||||
row["公告日"] = payload["publish_date"]
|
||||
# 这两列只用于下面按分析日判断是否已公布,返回前去掉
|
||||
row[_SINA_PUBLISHED] = payload.get("publish_date")
|
||||
row[_SINA_UPDATED] = payload.get("update_time")
|
||||
rows.append(row)
|
||||
elif isinstance(report_list, list):
|
||||
rows = [x for x in report_list if isinstance(x, dict)]
|
||||
@@ -1237,26 +1262,59 @@ def _get_financial_report_sina(
|
||||
|
||||
# Filter by curr_date:报告期在分析日之前还不够,财报还得在分析日之前已经公布。
|
||||
# 例:分析日 07-15 时 06-30 的半年报要到 8 月才公布,只按报告期筛会提前看到它。
|
||||
# 已公布 = 法定披露截止日已过(截止日当晚才发的也有,所以要严格晚于截止日),
|
||||
# 或新浪给的公告日已到。新浪对较早报告期给的「公告日」是第二年同期财报把它当
|
||||
# 对比数再刊出的日期(只会偏晚),不能单独用来筛,否则回看历史时早已公布的财报
|
||||
# 会被整批丢掉。没有公告日的行(旧结构)只按截止日判断。
|
||||
if curr_date and "报告日" in df.columns:
|
||||
# 新浪每期给两个日期,都不会早于这期数字公布的时间,但都可能被后来的报表改写:
|
||||
# publish_date 是最近一次刊出这期数字的报表日期(次年同期报表把它当对比数再刊出
|
||||
# 时会改写),update_time 是入库 / 最近改数的时间(多数是公布前一晚)。所以:
|
||||
# - 公告日早于分析日,或入库时间早于分析日当天 → 已公布(公告日当天收盘后才发的也有,
|
||||
# 所以要严格早于);
|
||||
# - 两个日期都晚于报告期一年以上(或都没有,如行列表)→ 原始公布日已被改写丢失,只能
|
||||
# 假定按时披露,过了法定截止日才算已公布。逾期披露的公司两个日期都还在一年内,
|
||||
# 不走这条,否则截止日一过就提前看到它还没发的财报。
|
||||
if curr_date:
|
||||
if "报告日" not in df.columns:
|
||||
logger.warning(
|
||||
"新浪财报 %s %s 没有报告日列,无法判断分析日 %s 时是否已公布,按无数据处理",
|
||||
code, report_type, curr_date,
|
||||
)
|
||||
return pd.DataFrame()
|
||||
cutoff = pd.to_datetime(curr_date)
|
||||
df["报告日"] = pd.to_datetime(df["报告日"], errors="coerce")
|
||||
available = _report_deadline(df["报告日"]) < cutoff
|
||||
if "公告日" in df.columns:
|
||||
published = pd.to_datetime(df["公告日"], errors="coerce")
|
||||
available |= published <= cutoff
|
||||
report = pd.to_datetime(df["报告日"], errors="coerce")
|
||||
df["报告日"] = report
|
||||
nat = pd.Series(pd.NaT, index=df.index, dtype="datetime64[ns]")
|
||||
published = (
|
||||
pd.to_datetime(df[_SINA_PUBLISHED], errors="coerce", format="%Y%m%d")
|
||||
if _SINA_PUBLISHED in df.columns else nat
|
||||
)
|
||||
updated = (
|
||||
pd.to_datetime(pd.to_numeric(df[_SINA_UPDATED], errors="coerce"), unit="s", utc=True)
|
||||
.dt.tz_convert("Asia/Shanghai").dt.tz_localize(None)
|
||||
if _SINA_UPDATED in df.columns else nat
|
||||
)
|
||||
year_later = report + pd.DateOffset(years=1)
|
||||
original_lost = (published.isna() | (published >= year_later)) & (
|
||||
updated.isna() | (updated >= year_later)
|
||||
)
|
||||
available = (
|
||||
(published < cutoff)
|
||||
| (updated < cutoff)
|
||||
| (original_lost & (_report_deadline(report) < cutoff))
|
||||
)
|
||||
df = df[available]
|
||||
|
||||
# 最新的报告期在前(不依赖接口返回的顺序),下面只取前 8 期
|
||||
if "报告日" in df.columns:
|
||||
order = pd.to_datetime(df["报告日"], errors="coerce").sort_values(
|
||||
ascending=False, na_position="last"
|
||||
)
|
||||
df = df.loc[order.index]
|
||||
|
||||
# Filter by frequency (annual = month 12 reports only)
|
||||
if freq.lower() == "annual" and "报告日" in df.columns:
|
||||
months = pd.to_datetime(df["报告日"], errors="coerce").dt.month
|
||||
df = df[months == 12]
|
||||
|
||||
# 公告日只用于上面的筛选;较早报告期的值是后来的刊出日,放进表里会误导分析师
|
||||
return df.drop(columns=["公告日"], errors="ignore").head(8)
|
||||
# 两个日期列只用于上面的筛选;较早报告期的日期是后来改写的,放进表里会误导分析师
|
||||
return df.drop(columns=[_SINA_PUBLISHED, _SINA_UPDATED], errors="ignore").head(8)
|
||||
|
||||
|
||||
def get_balance_sheet(
|
||||
|
||||
+3
-3
@@ -206,9 +206,9 @@ with st.sidebar:
|
||||
|
||||
start_req = st.session_state.pop("start_analysis", None)
|
||||
if start_req:
|
||||
# 唯一的消费点,所以模型校验挂在这里。`start_analysis` 有 5 个生产者——侧栏
|
||||
# 「开始分析」、侧栏「未完成任务」续跑、历史记录、报告页「重新分析」、错误页
|
||||
# 「继续未完成任务」——只在按钮里守一个,其余四条路径照样把空模型发给 API
|
||||
# 唯一的消费点,所以模型校验挂在这里。`start_analysis` 有 4 个生产者——侧栏
|
||||
# 「开始分析」、侧栏「未完成任务」续跑、报告页「重新分析」、错误页「继续未完成
|
||||
# 任务」(历史记录只把它清空)——只在按钮里守一个,其余三条路径照样把空模型发给 API
|
||||
# (实测踩到:深模型选「Custom model ID」且留空、走续跑路径,收到 `but you passed .`)。
|
||||
# 校验的是 `_build_config()` 的产物,也就是真正要发出去的那份配置。
|
||||
config = _build_config()
|
||||
|
||||
@@ -64,8 +64,8 @@ def validate_model_selection(config: dict | None = None) -> str | None:
|
||||
界面完全对不上号的错误。
|
||||
|
||||
**必须同时挂在唯一的消费点**(web/app.py 拿到 start_analysis 之后、真正起线程之前):
|
||||
`start_analysis` 有 5 个生产者——侧栏「开始分析」、侧栏「未完成任务」续跑、历史记录、
|
||||
报告页「重新分析」、错误页「继续未完成任务」——只在按钮里守一个,其余四条路径照样
|
||||
`start_analysis` 有 4 个生产者——侧栏「开始分析」、侧栏「未完成任务」续跑、
|
||||
报告页「重新分析」、错误页「继续未完成任务」(历史记录只把它清空)——只在按钮里守一个,其余三条路径照样
|
||||
把空模型发出去(实测踩过:深模型选「Custom model ID」且留空,走续跑路径,
|
||||
收到 `but you passed .`)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user