#!/usr/bin/env python3
"""BUY FRAMEWORK v3.2 (3 Oct 2026: silence-at-80% STRONG BUY removed, it failed on 679 new events). Usage: python3 buyscore.py <contract> [chain]   (key: env TWAPI_KEY or /opt/common/twitterapi_key or ~/.twapi_key)
All X calls go through xsafe (the shared scam/bot filter + credit guard): http://159.223.88.168/hx-c9ois/fw/xsafe.py
Sentiment rules from the 3 Oct stage study (606 coins, ATH then 20/40/60/70/80/90% under it, mood READ by meaning on
1,514 windows, plus a 12h time grid on 189 deep coins; both time halves, both chains, every size band):
  * At every depth, coins where NO real person posted in the last 12-24h did 20 to 40 points better over 7 days
    than coins where people were talking. Euphoric, frustrated and hopeful talk after a dump = worst.
  * Nobody posts "I give up": resigned posts are under 1%. Giving up = going silent.
  * 90%+ under ATH + silent 24h + trade volume dried (<=20% of peak day): median +8% / mean +21% in 7d, 2x before -50% 71%.
  * People RETURNING after silence while price pops 15%+ = fake bounce (median -32%).
Mood is never scored with keywords: real posts are printed and the reader judges the feeling.
"""
import json, sys, time, math, re, os, urllib.request, urllib.parse, datetime as dt
UA = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 Chrome/128 Safari/537.36", "Accept": "application/json"}
BKK = dt.timezone(dt.timedelta(hours=7))


def load_xsafe():
    import importlib.util
    for p in ['/opt/common/xsafe/xsafe.py', os.path.expanduser('~/.xsafe/xsafe.py')]:
        if os.path.exists(p) and (p.startswith('/opt') or (time.time() - os.path.getmtime(p) < 6 * 3600 and 'def ensure_latest' in open(p).read())): break
    else:
        p = os.path.expanduser('~/.xsafe/xsafe.py'); os.makedirs(os.path.dirname(p), exist_ok=True)
        urllib.request.urlretrieve("http://159.223.88.168/hx-c9ois/fw/xsafe.py", p)
    sp = importlib.util.spec_from_file_location("xsafe", p); m = importlib.util.module_from_spec(sp); sp.loader.exec_module(m)
    m.ensure_latest(0)    # Ben 3 Oct 2026: every run uses the latest spam filter
    return m


def get(u, h=UA, tries=4):
    for i in range(tries):
        try:
            with urllib.request.urlopen(urllib.request.Request(u, headers=h), timeout=40) as r: return json.load(r)
        except urllib.error.HTTPError as e:
            if e.code in (401, 402, 403, 404): return None   # no retry on auth / credits / missing
            time.sleep(4 + 6 * i)
        except Exception: time.sleep(3)
    return None

def rsi(cl, n=14):
    if len(cl) < n + 1: return None
    g = sum(max(cl[i] - cl[i - 1], 0) for i in range(-n, 0)); l = sum(max(cl[i - 1] - cl[i], 0) for i in range(-n, 0))
    return 100.0 if l == 0 else 100 - 100 / (1 + g / l)

def k(x): return f"${x/1e6:.2f}M" if x >= 1e6 else f"${x/1e3:.0f}k"

def main(ca, chain=None):
    ds = get(f"https://api.dexscreener.com/latest/dex/tokens/{ca}")
    pairs = [p for p in (ds or {}).get('pairs') or [] if p['baseToken']['address'].lower() == ca.lower() and p.get('marketCap')]
    if chain: pairs = [p for p in pairs if p['chainId'] == chain] or pairs
    if not pairs: print("NO DATA: DexScreener has no pool for this contract."); return
    p = max(pairs, key=lambda p: (p.get('volume', {}).get('h24') or 0))
    net = {'solana': 'solana', 'robinhood': 'robinhood'}.get(p['chainId'], p['chainId'])
    mc = p['marketCap']; price = float(p['priceUsd']); sup = mc / price; pool = p['pairAddress']
    now = time.time()
    base = f"https://api.geckoterminal.com/api/v2/networks/{net}/pools/{pool}/ohlcv"
    dly = get(f"{base}/day?limit=180&currency=usd&token={ca}") or {}
    hr = get(f"{base}/hour?limit=1000&currency=usd&token={ca}") or {}
    D = sorted((dly.get('data') or {}).get('attributes', {}).get('ohlcv_list', []))
    H = sorted((hr.get('data') or {}).get('attributes', {}).get('ohlcv_list', []))
    if len(H) < 30:
        age = (now - p.get('pairCreatedAt', now * 1000) / 1000) / 3600
        print(f"{p['baseToken']['symbol']}: WAIT (TOO YOUNG)\nOnly {age:.0f} hours old. This framework buys coins 70%+ under a top that is 3+ days old, with X gone quiet. Nothing to score yet.\nMarket cap {k(mc)} | Chart: https://dexscreener.com/{p['chainId']}/{pool}\nCA: {ca}")
        return
    ath = max([r[2] for r in D] + [r[2] for r in H])
    ath_t = max((r for r in H if r[2] == max(x[2] for x in H)), key=lambda r: r[0])[0]
    if D and max(r[2] for r in D) > max(r[2] for r in H) * 1.02:
        ath_t = max(r for r in D if r[2] == max(x[2] for x in D))[0]
    cl = [r[4] for r in H]; vo = [r[5] for r in H]
    dd = 1 - price / ath; ath_age_h = (now - ath_t) / 3600
    l72 = min(r[3] for r in H[-72:]); near_low = price / l72
    r1 = rsi(cl); ret24 = price / cl[-25] - 1 if len(cl) > 25 else 0
    v24 = sum(vo[-24:]); v24pk = max(sum(vo[i - 24:i]) for i in range(24, len(vo) + 1))
    vdecay = v24 / v24pk if v24pk else 1
    sym = p['baseToken']['symbol']
    try:
        xs = load_xsafe()
        cnow = xs.search(ca, since=now - 24 * 3600, until=now, pages=10)         # contract, FULL last 24h (reads to the end, never "last 20")
        cprev = xs.search(ca, since=now - 48 * 3600, until=now - 24 * 3600)     # the day before
        chain_words = {'robinhood': '(robinhood OR rh OR long OR pons)', 'solana': '(solana OR sol OR pump)'}.get(p['chainId'], '')
        nm = (p['baseToken'].get('name') or '').strip()
        # cashtag must stay UNQUOTED: X reads a quoted "$SI" as the plain word "si" (3 Oct 2026, matched Spanish posts)
        tq = f'(${sym} OR "{nm}")' if nm and nm.lower() != sym.lower() else f"${sym}"
        # Ben 3 Oct 2026, STANDING: real people post the TICKER, not the contract. The crowd read is the ticker read.
        # one closed window per 4h (a busy ticker fills 10 pages inside the newest 4h, so one 24h read is never enough)
        tw4 = [xs.search(tq, since=now - (q + 1) * 4 * 3600, until=now - q * 4 * 3600, pages=3) for q in range(5, -1, -1)]
        tick = dict(real=[t for w in tw4 for t in w['real']], complete=all(w['complete'] for w in tw4))
        tprev = xs.search(tq, since=now - 48 * 3600, until=now - 24 * 3600, pages=3)
    except Exception as e:
        print(f"{sym}: NO VERDICT (X DATA MISSING)\nX search refused or failed: {str(e)[:160]}. No buy call without X.\nMarket cap {k(mc)} | {dd*100:.0f}% under the top | Chart: https://dexscreener.com/{p['chainId']}/{pool}\nCA: {ca}")
        return
    TS = xs.ts
    real24 = cnow['real']; spam24 = cnow['spam']; real_prev = cprev['real']
    # Ben 3 Oct 2026: counts that are compared with the study (old windows) use COMPARABLE posts only: real people with
    # 3+ likes (X search keeps only those after ~7 days). Posts under 2h old have no settled likes yet, so they count as
    # present (never a false silent). Mood reading still uses every real post.
    okc = lambda t: (t.get("likeCount") or 0) >= xs.COMPARABLE_LIKES or now - xs.ts(t) < xs.SETTLE_H * 3600
    real24_all = real24; real24 = [t for t in real24 if okc(t)]; real_prev = [t for t in real_prev if okc(t)]
    complete = not cnow['has_more']
    win = [sum(1 for t in real24 if now - (q + 1) * 4 * 3600 <= TS(t) < now - q * 4 * 3600) for q in range(5, -1, -1)]
    silent24 = complete and len(real24) == 0
    silent12 = complete and sum(win[-3:]) == 0
    returning = len(real_prev) == 0 and win[-1] > 0
    seen = {t.get('id') for t in real24_all}
    _addr = re.compile(r'0x[0-9a-fA-F]{40}|[1-9A-HJ-NP-Za-km-z]{32,44}')
    def _same_coin(t):
        tx = t.get('text', '')
        if ca.lower() in tx.lower() or (nm and nm.lower() in tx.lower()): return True
        if f"${sym}".lower() not in tx.lower(): return False
        return not [a for a in _addr.findall(tx) if a.lower() != ca.lower()]
    # local gaps in xsafe seen 3 Oct on SI: one-line reply farms under an account's post, and "I qualify / allocation" claim waves
    _farm = re.compile(r"qualif|allocation|eligib|airdrop|claim|reward", re.I)
    def _human(t):
        tx = re.sub(r'@\w+|\$\w+|https?://\S+', '', t.get('text', '')).strip()
        if t.get('isReply') and len(tx.split()) <= 4: return False
        if _farm.search(t.get('text', '')) and 'http' in t.get('text', ''): return False
        return True
    tick_real = [t for t in tick['real'] if _same_coin(t) and _human(t)]
    tprev_real = [t for t in tprev['real'] if _same_coin(t) and _human(t)]
    twin = [sum(1 for t in w['real'] if _same_coin(t) and _human(t)) for w in tw4]
    tw_full = [w['complete'] for w in tw4]
    mood = sorted(tick_real, key=TS, reverse=True)
    spam_driven = dd >= 0.5 and len(real24) == 0 and len(spam24) >= 10
    rot = get("http://159.223.88.168/rotation.json") or {}
    # golden pocket module (tested 3 Oct: 0.618 touch alone about 0; with order block / buy shift / X quiet +18 to +29% per trade)
    hi = [r[2] for r in H]; lo = [r[3] for r in H]; op = [r[1] for r in H]
    iT = max(range(len(H)), key=lambda q: hi[q]); Ltop = min(lo[:iT + 1]); Htop = hi[iT]
    gp = None
    if Htop / Ltop >= 3 and iT < len(H) - 1:
        lvl = lambda r: Htop - r * (Htop - Ltop)
        retr = (Htop - price) / (Htop - Ltop)
        broke = any(cl[q] < lvl(0.75) for q in range(iT + 1, len(H)))
        zl, zh = lvl(0.666), lvl(0.618)
        gi = max(range(max(0, iT - 200), iT + 1), key=lambda q: (cl[q] - op[q]) if cl[q] > op[q] else -1)
        ob = next(((lo[q], op[q]) for q in range(gi - 1, -1, -1) if cl[q] < op[q]), None)
        ob_in = ob is not None and ob[0] <= zh * 1.03 and ob[1] >= zl * 0.97
        g6 = sum(vo[q] for q in range(len(H) - 6, len(H)) if cl[q] >= op[q]); r6 = sum(vo[q] for q in range(len(H) - 6, len(H)) if cl[q] < op[q])
        g24 = sum(vo[q] for q in range(len(H) - 24, len(H)) if cl[q] >= op[q]); r24 = sum(vo[q] for q in range(len(H) - 24, len(H)) if cl[q] < op[q])
        shift = g6 / (g6 + r6 + 1e-9) - g24 / (g24 + r24 + 1e-9)
        gp = dict(retr=retr, inzone=0.6 <= retr <= 0.75 and not broke, broke=broke, ob=ob_in, shift=shift, zone=(lvl(0.75) * sup, zh * sup), pump=Htop / Ltop)

    deep90 = dd >= 0.90 and ath_age_h >= 72
    deep80 = dd >= 0.80 and ath_age_h >= 72
    dried = vdecay <= 0.20
    # fresh touch: hours since the price FIRST closed 80%+ under the top (tested: entry at the touch +15 to +23%/trade, 12h later +2%)
    iA = max(range(len(cl)), key=lambda q: cl[q]); first80 = next((q for q in range(iA + 1, len(cl)) if cl[q] <= cl[iA] * 0.2), None)
    fresh_h = (len(cl) - 1 - first80) if first80 is not None else None
    fresh = fresh_h is not None and fresh_h <= 3
    chasing = ret24 > 0.38
    fake_bounce = returning and ret24 > 0.15
    talking = len(real24) > 0 or len(tick_real) >= 3

    if chasing: word, why = "NO BUY (CHASING)", f"up {ret24*100:.0f}% in 24h. Buying after a pump loses on average."
    elif fake_bounce: word, why = "NO BUY (FAKE BOUNCE)", "people came back after a silent day while price popped. In the study that bounce faded (median -32% in 7 days)."
    elif spam_driven: word, why = "NO BUY (SPAM-DRIVEN)", "real people are silent but bots and scam links keep it loud. That did worse than random."
    elif deep80 and silent12 and fresh: word, why = "WAIT (SHORT-TERM BOUNCE NOT PROVEN)", "just fell 80%+ under the top and nobody real is posting. The first test said +23% per trade, but on 679 NEW events (425 coins) it failed: silent -6% vs talking -7% per trade. All 1,060 events: +1% vs 0%. Silence alone is not a buy."
    elif deep80 and silent12: word, why = "WAIT (SHORT-TERM BOUNCE NOT PROVEN)", f"80%+ under the top and nobody real posting (first drop {fresh_h}h ago). Re-test on 1,060 events: silence gives no edge (+1% vs 0% per trade)."
    elif gp and gp['inzone'] and (r1 is not None and r1 < 25): word, why = "WAIT (KNIFE IN POCKET)", "in the golden pocket but RSI 1h under 25. Oversold at the pocket did worse, wait for it to settle."
    elif gp and gp['inzone'] and not talking and (gp['ob'] or gp['shift'] > 0.1): word, why = "BUY (GOLDEN POCKET)", "in the 0.618 to 0.75 zone with an order block or buyers stepping in, and nobody hyping it. Stop: 1h close under the 0.75 level."
    elif gp and gp['inzone'] and (gp['ob'] or gp['shift'] > 0.1): word, why = "BUY small (GOLDEN POCKET)", "in the 0.618 to 0.75 zone with an order block or buy shift, but people are still talking. Stop: 1h close under the 0.75 level."
    elif dd >= 0.6 and silent12: word, why = "WAIT (CLOSE)", "60 to 80% under the top and people went silent. Better than when people talk (-3% vs -15% per trade) but not profitable yet. Buy zone is 80%+."
    elif talking and dd >= 0.5: word, why = "WAIT (PEOPLE STILL TALKING)", "dipped, but real people are still posting. Read the mood below: hopeful, excited or angry = not the bottom."
    elif dd < 0.80: word, why = "WAIT", f"only {dd*100:.0f}% under the top. Silence only paid at 80%+ down."
    else: word, why = "WAIT", "deep, but the top is under 3 days old or X data is incomplete. Let it base."

    t = dt.datetime.now(BKK).strftime('%d %b %H:%M Bangkok')
    print(f"{sym}: {word}\n{why}\n")
    print(f"Time {t} | chain {p['chainId']} | market cap {k(mc)} | top {k(ath*sup)} ({dd*100:.0f}% under, top {ath_age_h/24:.1f} days ago)")
    print(f"Price vs 72h low: {near_low:.2f}x | RSI 1h: {(r1 or 0):.0f} | 24h: {ret24*100:+.0f}% | trading volume last 24h vs peak day: {vdecay*100:.0f}%")
    vol4 = [sum(vo[max(0, len(vo) - (q + 1) * 4):len(vo) - q * 4]) for q in range(5, -1, -1)]
    print("Per 4h, oldest to newest (last 24h):")
    print(f"  TICKER CROWD (real people, ${sym} or name, other coins with the same ticker removed): " + ' > '.join(f"{x}{'' if c else '+'}" for x, c in zip(twin, tw_full)) + ('' if tick['complete'] else '  (+ = at least, window busier than read)') + f" | day before: {len(tprev_real)}" + ('' if tprev['complete'] else '+'))
    print("  trading volume: " + ' > '.join(k(x) for x in vol4))
    print("  trading volume above; contract posts are used ONLY for the tested silence rule, never for the mood: " + ' > '.join(str(x) for x in win) + f" (day before {len(real_prev)})")
    if mood:
        print("READ THE TICKER CROWD by meaning, name the cheat-sheet phase. Flat, doubtful, bored = disbelief (what a buyer wants to see). Hopeful, excited = not yet. Euphoric = sell some. Never report bots or scam links.")
        for tw in mood[:25]:
            ts = dt.datetime.fromtimestamp(TS(tw), BKK).strftime('%d %b %H:%M')
            body = re.sub(r'\s+', ' ', tw.get('text', ''))[:170]; who = (tw.get('author') or {}).get('userName', '?')
            print(f"  {ts} @{who}: {body}")
    else:
        print(f"Ticker crowd: no real person posted ${sym} or the name in 24h" + ("" if tick["complete"] else " (incomplete read)") + ".")
    if rot: print(f"Market: BTC {rot.get('btc')} ({rot.get('btc_r7',0):+.1f}% 7d). Check the 5-state macro light before sizing.")
    if gp: print(f"Golden pocket: pullback {gp['retr']*100:.0f}% of the {gp['pump']:.1f}x leg | zone {k(gp['zone'][0])} to {k(gp['zone'][1])} | 1h closed under 0.75: {'yes' if gp['broke'] else 'no'} | order block in zone: {'yes' if gp['ob'] else 'no'} | buy shift {gp['shift']:+.2f}")
    print(f"Chart: https://dexscreener.com/{p['chainId']}/{pool}\nCA: {ca}")


if __name__ == '__main__':
    main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
