167 lines
11 KiB
Python
167 lines
11 KiB
Python
|
|
"""安全边际三情景的离线单测(不连库)。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()
|