-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathevaluator.py
More file actions
321 lines (254 loc) · 9.8 KB
/
evaluator.py
File metadata and controls
321 lines (254 loc) · 9.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
"""Score normalized market data and derive portfolio recommendations."""
from __future__ import annotations
from typing import Callable, Dict, Mapping, Tuple
from data_fetcher import RawFinancialData
# Rebalanced weights: added revenue_growth and current_ratio,
# redistributed from peer_relative and peg which had less signal.
WEIGHTS: Mapping[str, float] = {
"asset_value": 0.12,
"pe": 0.10,
"pb": 0.05,
"peg": 0.07,
"de_ratio": 0.05,
"roe": 0.10,
"fcf_yield": 0.10,
"dividend": 0.05,
"margin_of_safety": 0.12,
"peer_relative": 0.07,
"piotroski": 0.05,
"revenue_growth": 0.07,
"current_ratio": 0.05,
}
def _interpolate(value: float, low: float, high: float, score_low: float, score_high: float) -> float:
"""Linearly interpolate a score between two anchor points, clamped to [score_low, score_high]."""
if high == low:
return (score_low + score_high) / 2
t = (value - low) / (high - low)
t = max(0.0, min(1.0, t))
return score_low + t * (score_high - score_low)
def score_asset_value(raw: RawFinancialData) -> float:
"""Reward companies trading below their net asset value per share.
Uses continuous interpolation instead of hard buckets.
"""
try:
nav_ratio = (raw["assets"] - raw["liabilities"]) / raw["shares"] / raw["price"]
except Exception:
return 50.0
if nav_ratio >= 1.5:
return 100.0
if nav_ratio >= 0.5:
return _interpolate(nav_ratio, 0.5, 1.5, 40.0, 100.0)
return _interpolate(nav_ratio, 0.0, 0.5, 20.0, 40.0)
def score_pe(raw: RawFinancialData) -> float:
"""Score trailing P/E with value bias and proper negative P/E handling.
Negative P/E means the company is loss-making — scored very low.
"""
pe = raw.get("pe")
if pe is None:
return 50.0
if pe < 0:
return 10.0 # Loss-making company
if pe == 0:
return 50.0
if pe <= 10:
return 100.0
if pe <= 25:
return _interpolate(pe, 10, 25, 100.0, 50.0)
if pe <= 40:
return _interpolate(pe, 25, 40, 50.0, 25.0)
return 15.0
def score_pb(raw: RawFinancialData) -> float:
"""Score price-to-book with continuous interpolation."""
pb = raw.get("pb")
if pb is None:
return 50.0
if pb < 0:
return 10.0 # Negative book value
if pb <= 1.0:
return 100.0
if pb <= 3.0:
return _interpolate(pb, 1.0, 3.0, 100.0, 50.0)
if pb <= 5.0:
return _interpolate(pb, 3.0, 5.0, 50.0, 20.0)
return 15.0
def score_peg(raw: RawFinancialData) -> float:
"""Score PEG with continuous interpolation.
PEG < 1 is undervalued relative to growth; > 2 is expensive.
"""
peg = raw.get("peg")
if peg is None:
return 50.0
if peg < 0:
return 15.0 # Negative earnings growth
if peg <= 0.5:
return 100.0
if peg <= 1.5:
return _interpolate(peg, 0.5, 1.5, 100.0, 60.0)
if peg <= 3.0:
return _interpolate(peg, 1.5, 3.0, 60.0, 20.0)
return 15.0
def score_de_ratio(raw: RawFinancialData) -> float:
"""Score debt-to-equity with continuous interpolation."""
de = raw.get("de_ratio")
if de is None:
return 50.0
if de < 0:
return 10.0 # Negative equity
if de <= 0.5:
return 100.0
if de <= 1.5:
return _interpolate(de, 0.5, 1.5, 100.0, 60.0)
if de <= 3.0:
return _interpolate(de, 1.5, 3.0, 60.0, 20.0)
return 10.0
def score_roe(raw: RawFinancialData) -> float:
"""Score return on equity with negative ROE handling.
Negative ROE (losses destroying equity) is scored very low.
Very high ROE (>40%) may indicate leverage risk — slight cap.
"""
roe = raw.get("roe")
if roe is None:
return 50.0
if roe < 0:
return 5.0 # Loss-making, destroying shareholder value
if roe >= 0.30:
return 95.0 # Cap slightly — extreme ROE may signal leverage
if roe >= 0.15:
return _interpolate(roe, 0.15, 0.30, 70.0, 95.0)
if roe >= 0.08:
return _interpolate(roe, 0.08, 0.15, 40.0, 70.0)
return _interpolate(roe, 0.0, 0.08, 15.0, 40.0)
def score_fcf_yield(raw: RawFinancialData) -> float:
"""Score free-cash-flow yield with continuous interpolation."""
free_cashflow = raw.get("free_cashflow")
price = raw.get("price")
shares = raw.get("shares")
if not free_cashflow or not price or not shares or price * shares == 0:
return 50.0
fcf_yield = free_cashflow / (price * shares)
if fcf_yield < 0:
return 10.0 # Cash-burning company
if fcf_yield >= 0.08:
return 100.0
if fcf_yield >= 0.03:
return _interpolate(fcf_yield, 0.03, 0.08, 70.0, 100.0)
if fcf_yield >= 0.01:
return _interpolate(fcf_yield, 0.01, 0.03, 40.0, 70.0)
return _interpolate(fcf_yield, 0.0, 0.01, 20.0, 40.0)
def score_dividend(raw: RawFinancialData) -> float:
"""Score dividend yield with yield-trap awareness.
Yields above 8% are penalized as they often signal unsustainable payouts
(yield traps). The sweet spot is 2-6%.
"""
dividend = raw.get("dividend")
price = raw.get("price")
if dividend is None or not price or price == 0:
return 30.0 # No dividend isn't necessarily bad, just not a plus
div_yield = dividend / price
if div_yield <= 0:
return 30.0
if div_yield <= 0.02:
return _interpolate(div_yield, 0.0, 0.02, 35.0, 60.0)
if div_yield <= 0.06:
return _interpolate(div_yield, 0.02, 0.06, 60.0, 100.0)
if div_yield <= 0.08:
return _interpolate(div_yield, 0.06, 0.08, 100.0, 70.0)
# Yield trap territory: very high yields often precede cuts
return _interpolate(div_yield, 0.08, 0.15, 70.0, 20.0)
def score_margin_of_safety(raw: RawFinancialData) -> float:
"""Score the discount to DCF-derived intrinsic value."""
intrinsic_value = raw.get("intrinsic_value")
price = raw.get("price")
if intrinsic_value is None or price is None or intrinsic_value <= 0:
return 50.0
margin = (intrinsic_value - price) / intrinsic_value
if margin >= 0.40:
return 100.0
if margin >= 0.10:
return _interpolate(margin, 0.10, 0.40, 60.0, 100.0)
if margin >= -0.10:
return _interpolate(margin, -0.10, 0.10, 40.0, 60.0)
if margin >= -0.50:
return _interpolate(margin, -0.50, -0.10, 15.0, 40.0)
return 10.0 # Trading at >50% premium to intrinsic value
def score_peer_relative(raw: RawFinancialData) -> float:
"""Score valuation relative to sector peer medians."""
def _score(value: float | None, median: float | None) -> float:
if value is None or median is None or median <= 0:
return 50.0
ratio = value / median
if ratio <= 0.7:
return 100.0
if ratio <= 1.0:
return _interpolate(ratio, 0.7, 1.0, 100.0, 70.0)
if ratio <= 1.5:
return _interpolate(ratio, 1.0, 1.5, 70.0, 35.0)
return 20.0
ps = raw.get("ps")
ev_ebitda = raw.get("ev_ebitda")
median_ps = raw.get("sector_ps_median")
median_ev = raw.get("sector_ev_ebitda_median")
return (_score(ps, median_ps) + _score(ev_ebitda, median_ev)) / 2
def score_piotroski(raw: RawFinancialData) -> float:
"""Score Piotroski F-Score with continuous interpolation."""
f_score = raw.get("piotroski_f_score")
if f_score is None:
return 50.0
# F-Score ranges from 0-9; linear interpolation
return _interpolate(f_score, 0, 9, 10.0, 100.0)
def score_revenue_growth(raw: RawFinancialData) -> float:
"""Score year-over-year revenue growth.
Revenue growth is a key driver of long-term value creation.
Declining revenue is a red flag; high growth is rewarded.
"""
growth = raw.get("revenue_growth")
if growth is None:
return 50.0
if growth < -0.10:
return 10.0 # Significant revenue decline
if growth < 0:
return _interpolate(growth, -0.10, 0.0, 10.0, 35.0)
if growth <= 0.10:
return _interpolate(growth, 0.0, 0.10, 35.0, 65.0)
if growth <= 0.25:
return _interpolate(growth, 0.10, 0.25, 65.0, 90.0)
return min(100.0, _interpolate(growth, 0.25, 0.50, 90.0, 100.0))
def score_current_ratio(raw: RawFinancialData) -> float:
"""Score the current ratio (current assets / current liabilities).
Measures short-term liquidity — the company's ability to pay
near-term obligations. Below 1.0 signals potential solvency risk.
"""
cr = raw.get("current_ratio")
if cr is None:
return 50.0
if cr < 0.5:
return 10.0 # Severe liquidity risk
if cr < 1.0:
return _interpolate(cr, 0.5, 1.0, 10.0, 40.0)
if cr <= 2.0:
return _interpolate(cr, 1.0, 2.0, 40.0, 90.0)
if cr <= 3.0:
return _interpolate(cr, 2.0, 3.0, 90.0, 100.0)
# Very high current ratio may indicate inefficient capital use
return _interpolate(cr, 3.0, 5.0, 100.0, 80.0)
def evaluate(raw: RawFinancialData) -> Tuple[float, str]:
"""Return a (score, decision) tuple for ``raw`` financial metrics."""
scorers: Dict[str, Callable[[RawFinancialData], float]] = {
"asset_value": score_asset_value,
"pe": score_pe,
"pb": score_pb,
"peg": score_peg,
"de_ratio": score_de_ratio,
"roe": score_roe,
"fcf_yield": score_fcf_yield,
"dividend": score_dividend,
"margin_of_safety": score_margin_of_safety,
"peer_relative": score_peer_relative,
"piotroski": score_piotroski,
"revenue_growth": score_revenue_growth,
"current_ratio": score_current_ratio,
}
subscores = {name: scorer(raw) for name, scorer in scorers.items()}
total_score = sum(subscores[key] * WEIGHTS[key] for key in WEIGHTS)
decision = "BUY" if total_score >= 70 else "HOLD" if total_score >= 40 else "SELL"
return round(total_score, 2), decision