akg-factor-bridge/test_valuation.py

167 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""安全边际三情景的离线单测不连库。2026-09-07 下一阶段方案第四件。
钉住五件事:
一,三情景的算法与方案四之三的恩捷股份手算逐项一致(悲观 42.64、中性 56.75、乐观 84.96
隐含市盈率 21.4 倍,赔率 0.87 比 1
二,四种不适用各有一句人话:每股收益为负、机构不足两家、市盈率缺、现价缺;另两种赔率不成立的情形也说得清。
预测期怎么挑优先年度Q4里覆盖最多的同数取更近的年份同一机构只留最近一篇。
四,取数层共用一次:券商行动(两个等长窗口)改用共用分箱后行为不变;收盘价的代码形态归一与日期区间。
五,卡上的整句与表格短写法。
开发机没有 pandas 与数据库驱动时只给缺席的模块装最小桩(与 test_judgement_snapshot.py 同一约定)。
跑法python3 test_valuation.py 或 pytest test_valuation.py
"""
import sys
import types
_STUBS = ("pandas", "psycopg", "pymysql", "dotenv")
for _n in _STUBS:
if _n not in sys.modules:
try:
__import__(_n)
except ImportError:
_m = types.ModuleType(_n)
if _n == "pandas":
_m.DataFrame = type("DataFrame", (), {})
_m.Series = type("Series", (), {})
sys.modules[_n] = _m
import card # noqa: E402
import logic_state as ls # noqa: E402
import sources # noqa: E402
def t(name, cond):
assert cond, name
print(" ok", name)
DS = "2026-09-02"
PRICE = 50.17
# 九家机构对 2026 年度的预测,最小、中位、最大与方案四之三的手算对得上。
EPS = [2.08, 2.20, 2.30, 2.32, 2.34, 2.37, 2.42, 2.50, 2.68]
PE = [20.5, 22.3, 23.6, 24.0, 24.253, 26.9, 27.5, 30.0, 31.7]
def rows_for(k="SZ002812", quarter="2026Q4", eps=EPS, pe=PE, date="2026-08-25"):
return [{"k": k, "date": date, "quarter": quarter, "org": f"机构{i}", "eps": e, "pe": p}
for i, (e, p) in enumerate(zip(eps, pe))]
def test_scenarios():
print("三情景与恩捷手算对表")
s = sources.scenarios(EPS, PE, PRICE, quarter="2026Q4", as_of="2026-08-25")
t("算得出na 为空", s["na"] is None and s["firms"] == 9)
t("悲观 42.64 元、下行 15.0%", s["pess"] == 42.64 and round(s["down"], 3) == -0.150)
t("中性 56.75 元、上行 13.1%", s["neut"] == 56.75 and round(s["up_neut"], 3) == 0.131)
t("乐观 84.96 元、上行 69.3%", s["opt"] == 84.96 and round(s["up_opt"], 3) == 0.693)
t("隐含市盈率 21.4 倍", s["implied_pe"] == 21.4)
t("赔率 0.87 比 1", s["odds"] == 0.87 and s["note"] is None)
t("每股收益与市盈率的三个数都带出来", s["eps"]["med"] == 2.34 and s["pe"]["med"] == 24.25)
print("四种不适用与两种赔率不成立")
t("每股收益为负", "负值" in sources.scenarios([-0.5, 1.2], [20, 30], PRICE)["na"])
t("机构不足两家", "不足 2 家" in sources.scenarios([2.0], [20], PRICE)["na"])
t("市盈率缺(只有一家给了)", "市盈率预测只有 1 家" in sources.scenarios([2.0, 2.2], [20, None], PRICE)["na"])
t("市盈率为负的不算数", "市盈率预测只有 0 家" in sources.scenarios([2.0, 2.2], [-3, 0], PRICE)["na"])
t("现价缺", sources.scenarios([2.0, 2.2], [20, 30], None)["na"] == "现价取不到")
lo = sources.scenarios([2.0, 2.2], [30, 40], 30.0) # 悲观 60 > 现价 30
t("悲观仍高于现价:赔率不成立并说明", lo["odds"] is None and "悲观情景仍高于现价" in lo["note"])
hi = sources.scenarios([2.0, 2.2], [20, 21], 60.0) # 中性 44.1 < 现价 60
t("中性低于现价:赔率不成立并说明", hi["odds"] is None and "中性情景低于现价" in hi["note"])
def test_period_and_org():
print("预测期怎么挑、同机构只留最近")
rows = (rows_for(quarter="2026Q4", eps=EPS[:3], pe=PE[:3]) +
rows_for(quarter="2027Q4", eps=EPS[:5], pe=PE[:5]) +
rows_for(quarter="2026Q2", eps=EPS, pe=PE))
box = sources._latest_by_org(rows) # noqa: SLF001
t("优先年度2026Q2 覆盖最多也不选,选 Q4 里覆盖最多的 2027Q4", sources._pick_period(box) == "2027Q4") # noqa: SLF001
box2 = sources._latest_by_org(rows_for(quarter="2026Q4", eps=EPS[:3], pe=PE[:3]) + # noqa: SLF001
rows_for(quarter="2027Q4", eps=EPS[3:6], pe=PE[3:6]))
t("同数取更近的年份", sources._pick_period(box2) == "2026Q4") # noqa: SLF001
t("没有年度预测时退回覆盖最多的", sources._pick_period(sources._latest_by_org( # noqa: SLF001
rows_for(quarter="2026Q2", eps=EPS[:4], pe=PE[:4]) + rows_for(quarter="2026Q3", eps=EPS[:2], pe=PE[:2]))) == "2026Q2")
dup = [{"k": "SZ002812", "date": "2026-07-01", "quarter": "2026Q4", "org": "机构A", "eps": 1.0, "pe": 10.0},
{"k": "SZ002812", "date": "2026-08-20", "quarter": "2026Q4", "org": "机构A", "eps": 2.0, "pe": 20.0},
{"k": "SZ002812", "date": "2026-08-10", "quarter": "2026Q4", "org": "机构B", "eps": 3.0, "pe": 30.0}]
b = sources._latest_by_org(dup) # noqa: SLF001
t("同机构多篇只留最近一篇", b["2026Q4"]["机构A"]["eps"] == 2.0 and len(b["2026Q4"]) == 2)
v = sources.valuation_scenarios(["SZ002812", "SH600000"], DS, {"SZ002812": PRICE, "SH600000": 10.0},
rows=rows_for() + dup)
t("按票给结果;没有研报行的票为 None", v["SH600000"] is None and v["SZ002812"]["na"] is None)
t("用的是 2026 年度、机构数按去重后算", v["SZ002812"]["quarter"] == "2026Q4" and v["SZ002812"]["firms"] == 11)
t("截止日取所用行里最近的报告日", v["SZ002812"]["as_of"] == "2026-08-25")
t("没有现价的票写现价取不到",
sources.valuation_scenarios(["SZ002812"], DS, {}, rows=rows_for())["SZ002812"]["na"] == "现价取不到")
def test_fetch_shared():
print("共用取数与券商行动不变")
seen = {}
def _reader(which, sql, params):
seen["sql"], seen["params"] = " ".join(sql.split()), params
return [
{"ts_code": "002812.SZ", "report_date": "2026-08-25", "quarter": "2026Q4", "org_name": "", "eps": "2.4", "pe": "22"},
{"ts_code": "002812.SZ", "report_date": "2026-08-20", "quarter": "2026Q4", "org_name": "", "eps": 2.2, "pe": None},
{"ts_code": "002812.SZ", "report_date": "2026-07-10", "quarter": "2026Q4", "org_name": "", "eps": 2.6, "pe": 25},
{"ts_code": "002812.SZ", "report_date": "2026-07-05", "quarter": "2026Q4", "org_name": "", "eps": 2.7, "pe": 26},
{"ts_code": "002812.SZ", "report_date": "2026-07-05", "quarter": "", "org_name": "", "eps": 2.7, "pe": 26},
{"ts_code": "002812.SZ", "report_date": None, "quarter": "2026Q4", "org_name": "", "eps": 2.7, "pe": 26},
]
rows = sources.broker_reports(["SZ002812"], DS, read_mysql=_reader)
t("查的是 90 个自然日、带市盈率列、按点分形态传代码",
"pe FROM gp_report_rc" in seen["sql"] and seen["params"] == ("002812.SZ", "2026-06-04", DS))
t("没有预测期或报告日的行不要;字符串数字归一", len(rows) == 4 and rows[0]["eps"] == 2.4 and rows[0]["pe"] == 22.0)
t("市盈率缺就是 None不当成零", rows[1]["pe"] is None)
sig = sources.broker_actions(["SZ002812"], DS, rows=rows)["SZ002812"]
t("券商行动:近 45 天07-19 之后)两家中位 2.3 对前 45 天两家中位 2.65,下修 13% 转弱",
sig["path"] == ls.PATH_BROKER and sig["signal"] == ls.SIG_DOWN and "下修 13%" in sig["why"])
t("覆盖没收缩,不到硬触发", not sig.get("hard") and sig["refs"][0]["quarter"] == "2026Q4")
t("不传 rows 时自己取,结果一样", sources.broker_actions(["SZ002812"], DS, read_mysql=_reader)["SZ002812"]["why"] == sig["why"])
t("读失败两路都是空", sources.broker_reports(["SZ002812"], DS, read_mysql=lambda *a: (_ for _ in ()).throw(OSError("x"))) == [])
def _price_reader(which, sql, params):
seen["psql"], seen["pparams"] = " ".join(sql.split()), params
return [{"ts_code": "002812.SZ", "close": "50.17"}, {"ts_code": "600000", "close": 10.5},
{"ts_code": "430047", "close": 3.0}, {"ts_code": "300750.SZ", "close": None}]
px = sources.close_prices(DS, read_mysql=_price_reader, code_col="symbol")
t("收盘价按前缀码索引,两种代码形态都认", px == {"SZ002812": 50.17, "SH600000": 10.5, "BJ430047": 3.0})
t("日期写成左闭右开区间", "`timestamp` >= %s AND `timestamp` < %s" in seen["psql"] and seen["pparams"] == (DS, "2026-09-03"))
t("收盘价读失败返回空字典", sources.close_prices(DS, read_mysql=lambda *a: (_ for _ in ()).throw(OSError("x")), code_col="symbol") == {})
def test_card_text():
print("卡上的文字")
s = sources.scenarios(EPS, PE, PRICE, quarter="2026Q4")
line = card.valuation_view(s)
t("整句:预测期、机构数、三个价与幅度、隐含市盈率、赔率",
line == "安全边际2026 年度预测9 家):悲观 42.64 元(-15.0%)、中性 56.75 元(+13.1%)、"
"乐观 84.96 元(+69.3%);隐含市盈率 21.4 倍;赔率 0.87 比 1中性上行对悲观下行")
t("短写法", card.valuation_short(s) == "-15%/+13%,赔率 0.87")
t("没有数据", card.valuation_view(None) == "安全边际:近三个月没有券商的盈利预测,算不出" and card.valuation_short(None) == "")
na = sources.scenarios([2.0], [20], PRICE)
t("不适用写原因", card.valuation_view(na) == "安全边际算不出:覆盖机构只有 1 家,不足 2 家" and card.valuation_short(na) == "算不出")
lo = sources.scenarios([2.0, 2.2], [30, 40], 30.0)
t("赔率不成立时整句写说明、短写法写不成立", "悲观情景仍高于现价" in card.valuation_view(lo) and card.valuation_short(lo).endswith("赔率不成立"))
t("预测期写成人话", card._period_cn("2026Q2") == "2026 年中期" and card._period_cn("x") == "X") # noqa: SLF001
t("恩捷的分歧不算大,不标注", not s["wide"] and s["spread"] == {"eps": 1.29, "pe": 1.55} and "分歧" not in line)
w = sources.scenarios([0.03, 1.0, 2.0], [20, 30, 40], 10.0) # 每股收益最高是最低的 67 倍
t("分歧极大:整句标注倍数、短写法带括号",
w["wide"] and "机构分歧极大(每股收益最高是最低的 66.7 倍" in card.valuation_view(w)
and card.valuation_short(w).endswith("(分歧极大)"))
def main():
test_scenarios()
test_period_and_org()
test_fetch_shared()
test_card_text()
print("ALL OK — 三情景对表 / 不适用四种 / 预测期与机构去重 / 共用取数与券商行动不变 / 收盘价 / 卡上文字 全部通过")
if __name__ == "__main__":
main()