#!/usr/bin/env python3
"""Monitor integrado de investigación, solo lectura.

- Primera ejecución crea línea base de activos.
- Contratos inactivos de la primera ejecución entran inmediatamente en pending.
- No trata contratos activos históricos como listados nuevos.
- Mide nuevos activos/reactivados en t=0,1,5,10,20,30,60 segundos.
"""
from __future__ import annotations
import argparse,csv,json,signal,threading,time
from datetime import datetime,timezone
from pathlib import Path
import requests
EX=('bitunix','blofin','binance'); WINDOWS=(0,1,5,10,20,30,60); STOP=False
H={'User-Agent':'listing-research-monitor/4.0'}
def now():return datetime.now(timezone.utc).isoformat()
def f(x):
 try:return float(x)
 except:return None
def get(s,u,p=None):
 r=s.get(u,params=p,headers=H,timeout=8);r.raise_for_status();return r.json()
def arr(x):
 if isinstance(x,list):return [q for q in x if isinstance(q,dict)]
 if isinstance(x,dict):
  for k in ('data','result','symbols','instruments'):
   if isinstance(x.get(k),list):return [q for q in x[k] if isinstance(q,dict)]
 return []
def live(x):return str(x).upper() in {'OPEN','LIVE','TRADING','1','TRUE'}
def norm(e,z):return e+':'+z.upper().replace('-','')
def bf(z):
 z=z.upper().replace('-','');return z[:-4]+'-USDT' if z.endswith('USDT') else z
def discover(e,s):
 if e=='binance':
  p=get(s,'https://fapi.binance.com/fapi/v1/exchangeInfo');return [{'exchange':e,'symbol':x['symbol'],'active':live(x.get('status')),'status':x.get('status')} for x in p.get('symbols',[]) if x.get('contractType')=='PERPETUAL' and x.get('quoteAsset')=='USDT']
 if e=='blofin':
  p=get(s,'https://openapi.blofin.com/api/v1/market/instruments',{'instType':'SWAP'});o=[]
  for x in arr(p):
   z=str(x.get('instId','')).upper();st=x.get('state',x.get('status'))
   if z and 'USDT' in z:o.append({'exchange':e,'symbol':z,'active':live(st),'status':st})
  return o
 p=get(s,'https://fapi.bitunix.com/api/v1/futures/market/trading_pairs');o=[]
 for x in arr(p):
  z=str(x.get('symbol','')).upper();st=x.get('symbolStatus',x.get('status',x.get('state')))
  if z.endswith('USDT'):o.append({'exchange':e,'symbol':z,'active':live(st),'status':st})
 return o
def book(e,z,s):
 if e=='binance':b=get(s,'https://fapi.binance.com/fapi/v1/depth',{'symbol':z,'limit':5})
 elif e=='blofin':
  q=get(s,'https://openapi.blofin.com/api/v1/market/books',{'instId':bf(z),'sz':5});a=arr(q);b=a[0] if a else {}
 else:
  q=get(s,'https://fapi.bitunix.com/api/v1/futures/market/depth',{'symbol':z,'limit':5});b=q.get('data',q) if isinstance(q,dict) else {}
 bi,as_=b.get('bids',[]),b.get('asks',[]);bid,bq=(f(bi[0][0]),f(bi[0][1])) if bi else (None,None);ask,aq=(f(as_[0][0]),f(as_[0][1])) if as_ else (None,None);mid=(bid+ask)/2 if bid is not None and ask is not None else None
 if mid is None:return None
 return {'mid':mid,'bid':bid,'ask':ask,'spread_pct':(ask-bid)/mid*100,'imbalance_pct':((bq-aq)/(bq+aq)*100 if bq is not None and aq is not None and bq+aq else None)}
def write(p,fields,data):
 if not data:return
 p.parent.mkdir(parents=True,exist_ok=True);new=not p.exists()
 with p.open('a',newline='',encoding='utf-8') as h:
  w=csv.DictWriter(h,fieldnames=fields)
  if new:w.writeheader()
  w.writerows({k:r.get(k) for k in fields} for r in data)
def impact(ev,observed,ss,out,completed):
 ident=(ev['exchange'],ev['symbol'],ev['detected_at_utc'])
 if ident in completed:return
 start=time.monotonic();samples={e:{} for e in observed}
 for t in WINDOWS:
  while time.monotonic()-start<t and not STOP:time.sleep(.05)
  for e in observed:
   try:
    z=ev['symbol'] if e!='blofin' else bf(ev['symbol']);q=book(e,z,ss[e])
    if q:samples[e][str(t)]=q
   except:pass
 if not STOP:
  fields=['detected_at_utc','source_exchange','source_symbol','observed_exchange','observed_symbol','samples_json']
  write(out/'listing_impact_compact.csv',fields,[{'detected_at_utc':ev['detected_at_utc'],'source_exchange':ev['exchange'],'source_symbol':ev['symbol'],'observed_exchange':e,'observed_symbol':ev['symbol'] if e!='blofin' else bf(ev['symbol']),'samples_json':json.dumps(v,separators=(',',':'))} for e,v in samples.items() if v])
 completed.add(ident)
def main():
 global STOP
 ap=argparse.ArgumentParser();ap.add_argument('--out',default='integrated_data');ap.add_argument('--run-seconds',type=int,default=0);ap.add_argument('--discovery-interval',type=int,default=300);ap.add_argument('--pending-seconds',type=int,default=900);ap.add_argument('--pending-check',type=int,default=10);a=ap.parse_args()
 out=Path(a.out);ss={e:requests.Session() for e in EX};state={};pending={};completed=set();start=time.monotonic();lastd=lastp=0;baseline=True
 def stop(*_):
  global STOP
  STOP=True
 signal.signal(signal.SIGINT,stop);signal.signal(signal.SIGTERM,stop);ef=['detected_at_utc','event','exchange','symbol','canonical_symbol','active','status']
 while not STOP and (not a.run_seconds or time.monotonic()-start<a.run_seconds):
  t=time.monotonic()
  if t-lastd>=a.discovery_interval:
   lastd=t;allr=[]
   for e in EX:
    try:allr+=discover(e,ss[e]);print(e,'contratos',sum(x['exchange']==e for x in allr))
    except Exception as x:print(e,'ERROR',x)
   current={norm(x['exchange'],x['symbol']):x for x in allr}
   if baseline:
    state=current.copy()
    for k,x in current.items():
     if not x['active']:pending[k]=(x,t+a.pending_seconds)
    baseline=False;print('Línea base creada; contratos inactivos agregados a vigilancia.')
   else:
    events=[];stamp=now()
    for k,x in current.items():
     old=state.get(k);x={**x,'canonical_symbol':k,'detected_at_utc':stamp};state[k]=x
     if old is None:x['event']='NEW_TOKEN';events.append(x)
     elif old['active']!=x['active']:x['event']='REACTIVATED' if x['active'] else 'INACTIVE';events.append(x)
     if not x['active']:pending[k]=(x,t+a.pending_seconds)
    write(out/'discovery_events_compact.csv',ef,events)
    for x in events:
     if x.get('active') and x['event'] in ('NEW_TOKEN','REACTIVATED'):
      base=x['symbol'].upper().replace('-','')[:-4];valid={e for e in EX if any(r['exchange']==e and r['active'] and r['symbol'].upper().replace('-','').startswith(base) for r in allr)}
      if valid:threading.Thread(target=impact,args=(x,valid,ss,out,completed),daemon=True).start()
  if t-lastp>=a.pending_check:
   lastp=t
   for k,(x,until) in list(pending.items()):
    if t>until:pending.pop(k,None);continue
    try:
     z=next((q for q in discover(x['exchange'],ss[x['exchange']]) if q['symbol']==x['symbol']),None)
     if z and z['active']:
      z.update({'event':'REACTIVATED','detected_at_utc':now(),'canonical_symbol':k});pending.pop(k,None);state[k]=z;write(out/'discovery_events_compact.csv',ef,[z]);threading.Thread(target=impact,args=(z,{x['exchange']},ss,out,completed),daemon=True).start()
    except:pass
  try:
   q=book('bitunix','BTCUSDT',ss['bitunix'])
   if q:write(out/'btc_health.csv',['observed_at_utc','mid','spread_pct','imbalance_pct'],[{'observed_at_utc':now(),**q}])
  except:pass
  time.sleep(1)
 STOP=True;print('Cierre seguro. Datos guardados en',out)
if __name__=='__main__':main()
