"""Offline reconstruction of lab58: Python 3, standard library only.
Keep this script, lab58-cases.json and its source/frame CSV files together.
Run: python reproduce-lab58.py
Reads these local files and prints JSON. No network, trading or file writes.
Original MT5 bar labels are not asserted to be UTC or local wall-clock time.
"""
from pathlib import Path
from decimal import Decimal as D,getcontext
from datetime import datetime,timezone
import csv,hashlib,json
getcontext().prec=40
def digest(data):return hashlib.sha256(data).hexdigest()
def encode(epoch):return datetime.fromtimestamp(epoch,timezone.utc).strftime('%Y-%m-%d %H:%M')
def parse(raw,digits):
 rows=list(csv.DictReader(raw.decode('utf-8-sig').splitlines()));unit=D(10)**-digits
 for i,row in enumerate(rows):
  row['index']=i;row['epoch']=int(row['time_raw_epoch'])
  for key in ['open','high','low','close']:
   value=D(row[key]);row[key]=value.quantize(unit);assert abs(value-row[key])<D('1e-12')
  assert row['low']<=min(row['open'],row['close'])<=max(row['open'],row['close'])<=row['high']
 return rows
def calculate(rows):
 n=len(rows);c=[r['close'] for r in rows]
 keys=['rsi14','slowK14_3','signalD3','TR','ATR14_SMA','SMA50','BB_SMA20','BB_sigma_population20','BB_upper20_2','BB_lower20_2']
 out={key:[None]*n for key in keys};up=sum(max(c[i]-c[i-1],D(0)) for i in range(1,15))/14;down=sum(max(c[i-1]-c[i],D(0)) for i in range(1,15))/14
 for i in range(14,n):
  if i>14:up=(13*up+max(c[i]-c[i-1],D(0)))/14;down=(13*down+max(c[i-1]-c[i],D(0)))/14
  out['rsi14'][i]=100*up/(up+down) if up+down else None
 A=[None]*n;B=[None]*n;gaps=[0]*n
 for i,row in enumerate(rows):
  if i:gaps[i]=gaps[i-1]+int(row['epoch']-rows[i-1]['epoch']!=3600);out['TR'][i]=max(row['high']-row['low'],abs(row['high']-c[i-1]),abs(row['low']-c[i-1]))
  if i>=14:out['ATR14_SMA'][i]=sum(out['TR'][i-13:i+1])/14
  if i>=13:
   low=min(r['low'] for r in rows[i-13:i+1]);high=max(r['high'] for r in rows[i-13:i+1]);A[i]=row['close']-low;B[i]=high-low
  if i>=15:
   denominator=sum(B[i-2:i+1]);out['slowK14_3'][i]=100*sum(A[i-2:i+1])/denominator if denominator else None
  if i>=17 and all(v is not None for v in out['slowK14_3'][i-2:i+1]):out['signalD3'][i]=sum(out['slowK14_3'][i-2:i+1])/3
  if i>=19:
   mean=sum(c[i-19:i+1])/20;sigma=(sum((v-mean)**2 for v in c[i-19:i+1])/20).sqrt();out['BB_SMA20'][i]=mean;out['BB_sigma_population20'][i]=sigma;out['BB_upper20_2'][i]=mean+2*sigma;out['BB_lower20_2'][i]=mean-2*sigma
  if i>=49:out['SMA50'][i]=sum(c[i-49:i+1])/50
 out['gap_prefix']=gaps
 return out
def continuous(ind,a,b):return a>=0 and ind['gap_prefix'][b]-ind['gap_prefix'][a]==0
def scan(rows,ind,name,rules):
 output={'rsi_divergence':[],'stochastic_persistence':[]};previous={};p=rules['divergence'];pip=D('.01') if name.startswith('USDJPY') else D('.0001')
 for i in range(16,len(rows)-2):
  neighbours=[i-2,i-1,i+1,i+2]
  if any(ind['rsi14'][j] is None for j in neighbours+[i]):continue
  hi=all(rows[i]['close']>rows[j]['close'] and ind['rsi14'][i]>ind['rsi14'][j] for j in neighbours);lo=all(rows[i]['close']<rows[j]['close'] and ind['rsi14'][i]<ind['rsi14'][j] for j in neighbours)
  if not(hi or lo):continue
  kind='H' if hi else 'L';a=previous.get(kind);previous[kind]=i
  if a is None or rows[a]['time_mt5']<p['centre_range_start_inclusive'] or encode(rows[i+2]['epoch']+3600)>=p['recognition_range_end_exclusive']:continue
  if not p['spacing_bars_inclusive'][0]<=i-a<=p['spacing_bars_inclusive'][1] or not continuous(ind,a-2,i+2):continue
  dp=rows[i]['close']-rows[a]['close'];dr=ind['rsi14'][i]-ind['rsi14'][a]
  bearish=hi and dp>=pip and ind['rsi14'][a]>=60 and dr<=-3;bullish=lo and dp<=-pip and ind['rsi14'][a]<=40 and dr>=3
  if bearish or bullish:output['rsi_divergence'].append({'dataset':name,'event_index':i+2,'event_epoch':rows[i+2]['epoch']+3600,'first_pivot_center':a,'second_pivot_center':i,'direction':'bearish' if bearish else 'bullish'})
 p=rules['stochastic_persistence'];old_zone=None;streak=0
 for i,row in enumerate(rows):
  k=ind['slowK14_3'][i];zone='upper' if k is not None and k>=p['extreme_upper_inclusive'] else 'lower' if k is not None and k<=p['extreme_lower_inclusive'] else None
  if zone is None:streak=0
  elif zone==old_zone and i and row['epoch']-rows[i-1]['epoch']==3600:streak+=1
  else:streak=1
  old_zone=zone
  if streak!=p['consecutive_extreme_bars'] or i<59:continue
  if rows[i-streak+1]['time_mt5']<p['run_start_range_inclusive'] or encode(row['epoch']+3600)>=p['recognition_range_end_exclusive'] or not continuous(ind,i-59,i):continue
  up=row['close']>ind['SMA50'][i] and ind['SMA50'][i]>ind['SMA50'][i-10] and row['close']>rows[i-20]['close'];down=row['close']<ind['SMA50'][i] and ind['SMA50'][i]<ind['SMA50'][i-10] and row['close']<rows[i-20]['close']
  if zone=='upper' and up or zone=='lower' and down:output['stochastic_persistence'].append({'dataset':name,'event_index':i,'event_epoch':row['epoch']+3600,'direction':'upward' if up else 'downward'})
 return output
def reproduce(folder):
 meta=json.loads((folder/'lab58-cases.json').read_text(encoding='utf-8'));rules=meta['frozen_rules'];all_matches={'rsi_divergence':[],'stochastic_persistence':[]};loaded={};checks=[]
 def check(name,condition):assert condition,name;checks.append(name)
 for source in meta['source_datasets']:
  raw=(folder/source['public_file']).read_bytes();check(source['file']+' original SHA-256',digest(raw)==source['sha256']);rows=parse(raw,source['digits']);check(source['file']+' row count',len(rows)==source['bars']);ind=calculate(rows);loaded[source['file']]=(rows,ind,raw)
  found=scan(rows,ind,source['file'],rules)
  for key in all_matches:all_matches[key]+=found[key]
 for kind,case in meta['cases'].items():
  ordered=sorted(all_matches[kind],key=lambda x:(x['event_epoch'],rules['dataset_order'].index(x['dataset'])));chosen=ordered[0]
  check(kind+' earliest first match',chosen==case['selected_identity'])
  rows,ind,raw=loaded[chosen['dataset']];lines=raw.splitlines(keepends=True)
  for frame,data in case['frames'].items():
   contents=(folder/data['csv']).read_bytes();limit=data['last_visible_index'];check(kind+' '+frame+' CSV SHA-256',digest(contents)==data['source_csv_sha256']);check(kind+' '+frame+' exact original source prefix',contents==b''.join(lines[:limit+2]));check(kind+' '+frame+' last visible label',rows[limit]['time_mt5']==data['last_visible_bar'])
   for key,value in data['indicator_readings'].items():check(kind+' '+frame+' '+key,abs(ind[key][limit]-D(value))<D('1e-25'))
  i=chosen['event_index'];prefix=rows[:i+1];early=scan(prefix,calculate(prefix),chosen['dataset'],rules)
  check(kind+' no future needed at recognition',chosen in early[kind]);check(kind+' 12 subsequent records',case['frames']['after']['last_visible_index']==i+12)
 return {'pass':True,'checks':len(checks),'candidate_counts_not_performance_statistics':{k:len(v) for k,v in all_matches.items()},'source_prices_unchanged':True,'network_or_trading_calls':0,'time_basis':'Original MT5 labels; no independently verified broker offset or live received_at.'}
if __name__=='__main__':print(json.dumps(reproduce(Path(__file__).resolve().parent),indent=2))
