"""Fixed, transparent exploratory monthly screen; no tuned or predictive rating.""" import math import statistics as st import random VERSION = 'monthly-candidates-1.0' WEIGHTS = {'trend': .30, 'relative_strength': .20, 'spot_buying': .20, 'risk': .15, 'liquidity': .15} DAY = 86400 def clip(x): return min(1., max(0., x)) def valid_bar(r): return all(math.isfinite(r[k]) for k in ('o', 'h', 'l', 'c', 'qv')) and ( 0 < r['l'] <= min(r['o'], r['c']) <= max(r['o'], r['c']) <= r['h'] and r['qv'] > 0) def window(series, end, n): rows = [series.get(end - i * DAY) for i in reversed(range(n))] return rows if all(r is not None and valid_bar(r) for r in rows) else None def score(symbol, series, btc, end, *, min_median_quote=5e6): rows, benchmark = window(series, end, 91), window(btc, end, 91) if not rows or not benchmark: return None, 'missing_or_invalid_91_day_price_window' recent = rows[-30:] if any(r.get('taker_buy_valid') != 1 or not math.isfinite(r.get('taker_buy_qv', math.nan)) or not 0 <= r['taker_buy_qv'] <= r['qv'] for r in recent): return None, 'missing_or_invalid_30_day_active_buy_fields' quote = st.median(r['qv'] for r in recent) if quote < min_median_quote: return None, 'median_daily_quote_below_5m' if min_median_quote == 5e6 else 'median_daily_quote_below_research_threshold' closes = [r['c'] for r in rows] p = closes[-1] sma30, sma90 = st.mean(closes[-30:]), st.mean(closes[-90:]) r30, r90 = p / closes[-31] - 1, p / closes[0] - 1 btc30 = benchmark[-1]['c'] / benchmark[-31]['c'] - 1 peak, mdd = closes[0], 0. for price in closes: peak = max(peak, price) mdd = max(mdd, 1 - price / peak) vol = st.stdev(math.log(b / a) for a, b in zip(closes, closes[1:])) * math.sqrt(365) buy, total = sum(r['taker_buy_qv'] for r in recent), sum(r['qv'] for r in recent) share = buy / total parts = { 'trend': 10 * (.4 * (p > sma30) + .3 * (sma30 > sma90) + .3 * clip(.5 + r90 / .6)), 'relative_strength': 10 * clip(.5 + (r30 - btc30) / .4), 'spot_buying': 10 * clip(.5 + (share - .5) / .1), 'risk': 10 * (.5 * clip(1 - mdd / .5) + .5 * clip(1 - vol / 1.5)), 'liquidity': 10 * clip(math.log10(quote / 5e6) / 2), } extension = p / sma30 - 1 penalty = min(1., max(0., extension - .15) / .25) raw = max(0., sum(parts[k] * w for k, w in WEIGHTS.items()) - penalty) reasons = [] if raw < 6: reasons.append('score_below_6') if p <= sma30: reasons.append('close_not_above_sma30') if r30 <= 0: reasons.append('30d_return_not_positive') if mdd > .4: reasons.append('90d_close_drawdown_above_40pct') return dict(symbol=symbol, as_of=end + DAY, score=raw, display_score=f'{raw:.1f}', components=parts, penalty=penalty, close=p, sma30=sma30, sma90=sma90, return_30d=r30, return_90d=r90, excess_btc_30d=r30-btc30, max_drawdown_90d=mdd, annualized_vol_90d=vol, median_quote_30d=quote, taker_buy_quote_30d=buy, total_quote_30d=total, active_buy_share_30d=share, extension_sma30=extension, qualified=not reasons, exclusion_reasons=reasons, stance='Wait for buying/price confirmation' if share < .5 or penalty else 'Research candidate, not an entry order'), None def rank(rows): return sorted(rows, key=lambda r: (-r['score'], r['symbol'])) def sustained_score(symbol, series, btc, end): result, error = score(symbol, series, btc, end, min_median_quote=1e6) if error: return result, error recent = window(series, end, 30) result['median_quote_7d'] = st.median(r['qv'] for r in recent[-7:]) result['days_above_500k_30d'] = sum(r['qv'] >= 5e5 for r in recent) if result['median_quote_7d'] < 1e6 or result['days_above_500k_30d'] < 24: return None, 'insufficient_sustained_turnover' return result, None def relative_ratings(rows): """Strict empirical percentile over ALL scoreable peers, before risk gates. Ties receive equal ratings. No rank stretching over just the published ten. """ n = len(rows) for row in rows: lower = sum(other['score'] < row['score'] for other in rows) row.update(relative_score=10*lower/n if n > 1 else None, relative_lower_count=lower, relative_pool_n=n) def outcome(series, decision, days): rows = [series.get(decision + i * DAY) for i in range(days)] if not all(r is not None and valid_bar(r) for r in rows): return None entry = rows[0]['o'] return dict(entry_open=entry, exit_close=rows[-1]['c'], gross_return=rows[-1]['c']/entry-1, net_reference_return=rows[-1]['c']/entry-1-.002, mae=min(r['l']/entry-1 for r in rows), mfe=max(r['h']/entry-1 for r in rows)) def sensitivity(rows): """Leave one factor out, same originally qualified pool, not statistical CI.""" ranges = {r['symbol']: [i+1] for i, r in enumerate(rank(rows))} for omitted, weight in WEIGHTS.items(): ordered = sorted(rows, key=lambda r: ( -max(0, sum(r['components'][k]*w for k,w in WEIGHTS.items() if k != omitted)/(1-weight)-r['penalty']), r['symbol'])) for i, row in enumerate(ordered): ranges[row['symbol']].append(i+1) return {s: [min(v), max(v)] for s,v in ranges.items()} def mean_interval(values, seed=17092026): """Descriptive 3-observation circular block bootstrap; overlapping months remain.""" if len(values) < 12: return None rng = random.Random(seed) draws=[] for _ in range(2000): sample=[] while len(sample)