#!/usr/bin/env python3
# revision: 2026-09-17
"""historicaldata.net - independent source reconciliation tool.

Compare the CSV files you hold (a free sample or a purchased archive) with
public third-party records, so that agreement with the archive's own
verify.py is not the only evidence you have. verify.py proves that the files
are exactly what we published; this script asks a different question - do the
published values agree with records that we did not produce?

Usage:
    python3 reconcile.py --root sample_folder
    python3 reconcile.py --root sample_folder --sources cboe
    python3 reconcile.py --root sample_folder --symbols SPY,KO --json report.json
    python3 reconcile.py --root sample_folder --cache refs/      # keep the downloaded references
    python3 reconcile.py --root sample_folder --cache refs/ --offline

WHAT IS COMPARED, AND AGAINST WHAT

  cboe    Cboe publishes the official daily closing level of the indices it
          calculates (SPX, VIX, RUT, XSP, OEX, XEO, DJX, ...) as free CSV files.
          For every options file, the underlying_close carried by index option
          rows is compared with that official close. Weekly and other root
          aliases (SPXW, RUTW, VIXW, ...) are compared with the index they
          reference. Indices that Cboe does not publish (NDX and the
          Nasdaq/PHLX families) are listed as having no public reference.

  yahoo   Yahoo Finance's public chart endpoint returns daily close, volume,
          dividend and split records for stocks and ETFs. It is a secondary
          source, not an exchange record, and its values are restated for
          later splits. This script undoes that restatement using the source's
          own split records before comparing raw close and volume, then
          compares the dividend and split events themselves. The option rows'
          underlying_close for stocks and ETFs is compared the same way.
          Automated access is subject to that provider's terms; use
          --sources cboe if you would rather not query it.

  Cumulative adjustment (adj_close / close) is reported but not judged: the
  archive retains adjustments up to its own publication date while a live
  source includes every distribution up to today, so the two ratios differ
  whenever a distribution falls between those dates. The dividend and split
  event comparison covers what can be compared exactly.

WHAT A RESULT MEANS

  A price agrees when it matches within the publication precision (0.005,
  scaled by the split restatement where one applies). Volumes are reported by
  relative difference band, because consolidated volume revisions and
  session-window differences between providers make an exact test
  uninformative. Every disagreement is listed with both values and the date,
  so it can be checked against the file for that day. Agreement with a
  secondary source is corroboration, not proof that either side is right;
  disagreement identifies a row to investigate, not automatically an error in
  this archive. No result here restates the archive's own checks: run
  verify.py for integrity and structure.

  Exit status 0: the comparison completed (disagreements do not change it).
  Exit status 2: a requested reference could not be retrieved for one or more
  symbols; the summary says which. Exit status 1: usage error.

Python 3.8 or later, standard library only. Nothing outside --root and --cache
is read or written; the reference files are downloaded for your own
comparison and should not be redistributed.
"""
import argparse
import csv
import datetime as dt
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request

VERSION = '1.0.0'
STOCK = 'date,open,high,low,close,volume,vwap,transactions,adj_open,adj_high,adj_low,adj_close,adj_volume,adj_vwap,dividend,dividend_type,split'.split(',')
OPTION = 'contract,underlying,expiration,type,strike,style,quote_date,bid,bid_size,ask,ask_size,quote_time,volume,open_interest,open,high,low,close,trade_vwap,transactions,multileg_volume,active_minutes,last_trade_date,underlying_close,settlement_time,iv_bid,iv_ask,iv,iv_flag,delta,gamma,theta,vega,rho'.split(',')
CBOE_URL = 'https://cdn.cboe.com/api/global/us_indices/daily_prices/{sym}_History.csv'
CBOE_INDEX = ['SPX', 'VIX', 'DJX', 'OEX', 'XEO', 'XSP', 'RUT', 'RLV', 'RUI', 'RLG', 'MNX', 'VXN']
# Option roots that reference an index Cboe publishes under another symbol.
CBOE_ALIAS = {'SPXW': 'SPX', 'SPXQ': 'SPX', 'SPXPM': 'SPX', 'RUTW': 'RUT', 'VIXW': 'VIX',
              'XSPW': 'XSP', 'OEXW': 'OEX', 'DJXW': 'DJX', 'MRUT': 'RUT'}
# Index roots known to have no free official history at Cboe.
NO_REFERENCE = {'NDX', 'NDXP', 'NQX', 'HGX', 'OSX', 'UTY', 'XAU', 'SOX', 'BKX', 'XDA', 'XDB',
                'XDC', 'XDE', 'XDN', 'XDS', 'XDZ', 'DJIA', 'VXN'}
YAHOO_URL = ('https://query1.finance.yahoo.com/v8/finance/chart/{sym}'
             '?period1={p1}&period2={p2}&interval=1d&events=div%2Csplits')
USER_AGENT = 'Mozilla/5.0 (compatible; historicaldata-reconcile/' + VERSION + ')'
VOLUME_BANDS = [('within 0.1%', 0.001), ('within 1%', 0.01), ('within 5%', 0.05), ('beyond 5%', None)]
DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')


class SourceError(Exception):
    pass


# ---------------------------------------------------------------- retrieval

def fetch(url, cache_dir, name, offline):
    path = os.path.join(cache_dir, name) if cache_dir else None
    if path and os.path.isfile(path):
        with open(path, 'rb') as fh:
            return fh.read()
    if offline:
        raise SourceError('offline and not cached: ' + name)
    req = urllib.request.Request(url, headers={'User-Agent': USER_AGENT, 'Accept': '*/*'})
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            body = resp.read()
    except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
        raise SourceError('%s: %s' % (url, e))
    if path:
        os.makedirs(cache_dir, exist_ok=True)
        with open(path, 'wb') as fh:
            fh.write(body)
    time.sleep(0.4)
    return body


def num(s):
    try:
        return float(s)
    except (TypeError, ValueError):
        return None


def load_cboe(sym, cache_dir, offline):
    body = fetch(CBOE_URL.format(sym=sym), cache_dir, 'cboe_%s.csv' % sym, offline)
    text = body.decode('utf-8', 'replace')
    if text.lstrip().startswith('<'):
        raise SourceError('Cboe returned a page instead of CSV for ' + sym)
    rows = list(csv.reader(text.splitlines()))
    if not rows or len(rows[0]) < 2:
        raise SourceError('unexpected Cboe layout for ' + sym)
    head = [h.strip().upper() for h in rows[0]]
    col = head.index('CLOSE') if 'CLOSE' in head else len(head) - 1
    out = {}
    for r in rows[1:]:
        if len(r) <= col or not r[0].strip():
            continue
        d = r[0].strip()
        try:
            if '/' in d:
                m, day, y = d.split('/')
                d = '%s-%s-%s' % (y, m.zfill(2), day.zfill(2))
            v = float(r[col])
        except ValueError:
            continue
        out[d] = v
    return out


def load_yahoo(sym, first_date, cache_dir, offline):
    ysym = sym.replace('.', '-')
    p1 = int((dt.datetime.strptime(first_date, '%Y-%m-%d') - dt.timedelta(days=5)).replace(tzinfo=dt.timezone.utc).timestamp())
    p2 = int(time.time()) + 86400
    body = fetch(YAHOO_URL.format(sym=ysym, p1=p1, p2=p2), cache_dir, 'yahoo_%s.json' % ysym, offline)
    try:
        data = json.loads(body.decode('utf-8'))
        result = data['chart']['result'][0]
    except (ValueError, KeyError, IndexError, TypeError):
        raise SourceError('no chart data for ' + sym)
    quote = result['indicators']['quote'][0]
    adj = (result['indicators'].get('adjclose') or [{}])[0].get('adjclose') or []
    days = {}
    for i, ts in enumerate(result.get('timestamp') or []):
        d = dt.datetime.fromtimestamp(ts, dt.timezone.utc).strftime('%Y-%m-%d')
        c = quote['close'][i] if i < len(quote['close']) else None
        if c is None:
            continue
        days[d] = {'close': c, 'volume': quote['volume'][i] if i < len(quote['volume']) else None,
                   'adjclose': adj[i] if i < len(adj) else None}
    events = result.get('events') or {}
    divs = {}
    for v in (events.get('dividends') or {}).values():
        divs[dt.datetime.fromtimestamp(v['date'], dt.timezone.utc).strftime('%Y-%m-%d')] = float(v['amount'])
    splits = {}
    for v in (events.get('splits') or {}).values():
        d = dt.datetime.fromtimestamp(v['date'], dt.timezone.utc).strftime('%Y-%m-%d')
        splits[d] = (float(v['numerator']), float(v['denominator']))
    return {'days': days, 'dividends': divs, 'splits': splits}


def restate(ref, date):
    """Factor by which the source restated values dated before later splits."""
    f = 1.0
    for d, (n, m) in ref['splits'].items():
        if d > date and m:
            f *= n / m
    return f


# ---------------------------------------------------------------- local files

def read_header(path):
    with open(path, newline='', encoding='utf-8', errors='replace') as fh:
        return next(csv.reader(fh), [])


def find_files(root):
    stocks, options = [], []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
        for name in sorted(filenames):
            if not name.lower().endswith('.csv'):
                continue
            path = os.path.join(dirpath, name)
            head = read_header(path)
            if head == STOCK and re.match(r'^[A-Za-z0-9.\-]+_day', name):
                stocks.append((name.split('_day')[0], path))
            elif head == OPTION:
                options.append(path)
    return stocks, options


def read_stock(path):
    with open(path, newline='', encoding='utf-8', errors='replace') as fh:
        return [r for r in csv.DictReader(fh) if DATE_RE.match(r.get('date') or '')]


def read_option_underlyings(path):
    """{underlying: {underlying_close values}} for one daily options file."""
    out = {}
    with open(path, newline='', encoding='utf-8', errors='replace') as fh:
        for r in csv.DictReader(fh):
            u = r.get('underlying') or ''
            v = r.get('underlying_close') or ''
            if u and v:
                out.setdefault(u, set()).add(v)
    return out


# ---------------------------------------------------------------- comparisons

def price_ok(ours, ref, scale=1.0):
    return abs(ours - ref) <= 0.005 * (1.0 + scale) + abs(ref) * 1e-6


def compare_stock(sym, rows, ref):
    res = {'symbol': sym, 'rows': len(rows), 'close': {'compared': 0, 'agree': 0, 'exact': 0, 'disagree': []},
           'volume': {'compared': 0, 'bands': {b: 0 for b, _ in VOLUME_BANDS}, 'worst': []},
           'dividends': {'agree': [], 'amount_differs': [], 'archive_only': [], 'source_only': []},
           'splits': {'agree': [], 'ratio_differs': [], 'archive_only': [], 'source_only': []},
           'dates_not_in_source': [], 'adjustment_note': None}
    vol_diffs = []
    dates = set()
    for r in rows:
        d = r['date']
        dates.add(d)
        y = ref['days'].get(d)
        c = num(r['close'])
        if y is None:
            if c is not None:
                res['dates_not_in_source'].append(d)
            continue
        scale = restate(ref, d)
        if c is not None and y['close'] is not None:
            rc = y['close'] * scale
            res['close']['compared'] += 1
            if abs(c - rc) <= 0.005:
                res['close']['exact'] += 1
            if price_ok(c, rc, scale):
                res['close']['agree'] += 1
            else:
                res['close']['disagree'].append({'date': d, 'archive': c, 'source_restated': round(rc, 4)})
        v = num(r['volume'])
        if v is not None and y['volume']:
            rv = y['volume'] / scale
            rel = abs(v / rv - 1.0) if rv else None
            if rel is not None:
                res['volume']['compared'] += 1
                for band, lim in VOLUME_BANDS:
                    if lim is None or rel <= lim:
                        res['volume']['bands'][band] += 1
                        break
                vol_diffs.append((rel, d, v, round(rv)))
    vol_diffs.sort(reverse=True)
    res['volume']['worst'] = [{'date': d, 'archive': int(v), 'source_restated': rv, 'relative': round(rel, 4)} for rel, d, v, rv in vol_diffs[:5]]
    ours_div = {r['date']: num(r['dividend']) for r in rows if r.get('dividend')}
    for d, a in sorted(ours_div.items()):
        b = ref['dividends'].get(d)
        if b is None:
            res['dividends']['archive_only'].append({'date': d, 'archive': a})
        elif abs(a - b) <= max(0.001, 0.001 * abs(b)):
            res['dividends']['agree'].append({'date': d, 'amount': a})
        else:
            res['dividends']['amount_differs'].append({'date': d, 'archive': a, 'source': b})
    for d, b in sorted(ref['dividends'].items()):
        if d in dates and d not in ours_div:
            res['dividends']['source_only'].append({'date': d, 'source': b})
    ours_split = {r['date']: r['split'] for r in rows if r.get('split')}
    for d, s in sorted(ours_split.items()):
        try:
            frm, to = (float(x) for x in s.split(':'))
        except ValueError:
            frm, to = None, None
        y = ref['splits'].get(d)
        if y is None:
            res['splits']['archive_only'].append({'date': d, 'archive': s})
        elif frm and to and y[1] and abs(to / frm - y[0] / y[1]) < 1e-6:
            res['splits']['agree'].append({'date': d, 'archive': s, 'source': '%g:%g' % (y[1], y[0])})
        else:
            res['splits']['ratio_differs'].append({'date': d, 'archive': s, 'source': '%g:%g' % (y[1], y[0])})
    for d, y in sorted(ref['splits'].items()):
        if d in dates and d not in ours_split:
            res['splits']['source_only'].append({'date': d, 'source': '%g:%g' % (y[1], y[0])})
    last = rows[-1]
    lc, la = num(last['close']), num(last['adj_close'])
    y = ref['days'].get(last['date'])
    if lc and la and y and y.get('adjclose') and y['close']:
        res['adjustment_note'] = {'date': last['date'], 'archive_adj_ratio': round(la / lc, 6),
                                  'source_adj_ratio': round(y['adjclose'] / y['close'], 6),
                                  'note': 'Ratios reflect distributions after this date up to each side\'s own as-of date; not judged.'}
    return res


def compare_options(files, cboe, yahoo, sources, symbols, cache_dir, offline, errors):
    per = {}
    dates = []
    for path in files:
        date = os.path.basename(path)[:10]
        if not DATE_RE.match(date):
            continue
        dates.append(date)
        for u, vals in read_option_underlyings(path).items():
            if symbols and u not in symbols:
                continue
            per.setdefault(u, []).append((date, sorted(vals)))
    results = []
    for u in sorted(per):
        idx = CBOE_ALIAS.get(u, u)
        entry = {'underlying': u, 'reference': None, 'days': len(per[u]), 'compared': 0, 'agree': 0,
                 'disagree': [], 'multiple_values': [], 'not_in_source': 0}
        ref_days = None
        scale_for = None
        if idx in CBOE_INDEX:
            if 'cboe' not in sources:
                entry['reference'] = 'cboe (skipped)'
                results.append(entry)
                continue
            entry['reference'] = 'cboe official close for ' + idx
            if idx not in cboe:
                try:
                    cboe[idx] = load_cboe(idx, cache_dir, offline)
                except SourceError as e:
                    errors.append(str(e))
                    cboe[idx] = None
            ref_days = cboe[idx]
            if ref_days is not None:
                scale_for = lambda d: 1.0
        elif u in NO_REFERENCE:
            entry['reference'] = 'no public official history'
            results.append(entry)
            continue
        else:
            if 'yahoo' not in sources:
                entry['reference'] = 'yahoo (skipped)'
                results.append(entry)
                continue
            entry['reference'] = 'yahoo raw close (split-restatement undone)'
            if u not in yahoo:
                try:
                    yahoo[u] = load_yahoo(u, per[u][0][0], cache_dir, offline)
                except SourceError as e:
                    errors.append(str(e))
                    yahoo[u] = None
            if yahoo[u] is not None:
                ref_days = {d: y['close'] for d, y in yahoo[u]['days'].items() if y['close'] is not None}
                scale_for = lambda d, r=yahoo[u]: restate(r, d)
        if ref_days is None:
            entry['reference'] += ' (unavailable)'
            results.append(entry)
            continue
        for date, vals in per[u]:
            if len(vals) > 1:
                entry['multiple_values'].append({'date': date, 'values': vals})
            ref = ref_days.get(date)
            if ref is None:
                entry['not_in_source'] += 1
                continue
            scale = scale_for(date)
            rc = ref * scale
            for v in vals:
                ours = num(v)
                if ours is None:
                    continue
                entry['compared'] += 1
                if price_ok(ours, rc, scale):
                    entry['agree'] += 1
                else:
                    entry['disagree'].append({'date': date, 'archive': ours, 'source': round(rc, 4)})
        results.append(entry)
    return {'files': len(files), 'first_date': min(dates) if dates else None, 'last_date': max(dates) if dates else None,
            'underlyings': results}


# ---------------------------------------------------------------- reporting

def summarize(report):
    """Count every exact-precision comparison (prices and events); volumes are banded, not counted."""
    checks = agree = 0
    parts = {}
    for st in report['stocks']:
        if 'close' not in st:
            continue
        c = st['close']
        checks += c['compared']; agree += c['agree']
        parts.setdefault('stockClose', {'compared': 0, 'agree': 0})
        parts['stockClose']['compared'] += c['compared']; parts['stockClose']['agree'] += c['agree']
        for k in ('dividends', 'splits'):
            e = st[k]
            n = len(e['agree']) + len(e['amount_differs' if k == 'dividends' else 'ratio_differs']) + len(e['archive_only']) + len(e['source_only'])
            checks += n; agree += len(e['agree'])
            parts.setdefault('events', {'compared': 0, 'agree': 0})
            parts['events']['compared'] += n; parts['events']['agree'] += len(e['agree'])
        v = st['volume']
        parts.setdefault('volume', {'compared': 0, 'within1pct': 0})
        parts['volume']['compared'] += v['compared']
        parts['volume']['within1pct'] += v['bands'].get('within 0.1%', 0) + v['bands'].get('within 1%', 0)
    if report['options']:
        for e in report['options']['underlyings']:
            if not e['compared']:
                continue
            checks += e['compared']; agree += e['agree']
            key = 'optionIndex' if e['reference'].startswith('cboe') else 'optionEquity'
            parts.setdefault(key, {'compared': 0, 'agree': 0})
            parts[key]['compared'] += e['compared']; parts[key]['agree'] += e['agree']
            parts.setdefault('byUnderlying', {})[e['underlying']] = {'compared': e['compared'], 'agree': e['agree'], 'reference': e['reference']}
    return {'checks': checks, 'agree': agree, 'disagree': checks - agree,
            'agreementPct': round(100.0 * agree / checks, 2) if checks else None, 'parts': parts,
            'note': 'Exact-precision comparisons of prices and corporate-action events. Volume differences are reported by band and excluded from this count.'}


def print_stock(res):
    print('== %s  %d rows ==' % (res['symbol'], res['rows']))
    c = res['close']
    print('   close vs source: %d compared, %d agree (%d exact), %d disagree' % (c['compared'], c['agree'], c['exact'], len(c['disagree'])))
    for d in c['disagree'][:10]:
        print('      %s archive %s  source %s' % (d['date'], d['archive'], d['source_restated']))
    v = res['volume']
    if v['compared']:
        print('   volume vs source: %d compared; ' % v['compared'] + ', '.join('%s %d' % (b, v['bands'][b]) for b, _ in VOLUME_BANDS))
        w = v['worst'][0]
        print('      largest relative difference %.2f%% on %s (archive %d, source %d)' % (w['relative'] * 100, w['date'], w['archive'], w['source_restated']))
    dv = res['dividends']
    print('   dividends: %d agree, %d amount differs, %d archive only, %d source only' % (len(dv['agree']), len(dv['amount_differs']), len(dv['archive_only']), len(dv['source_only'])))
    for k in ('amount_differs', 'archive_only', 'source_only'):
        for d in dv[k][:10]:
            print('      %s %s' % (k.replace('_', ' '), json.dumps(d)))
    sp = res['splits']
    print('   splits: %d agree, %d ratio differs, %d archive only, %d source only' % (len(sp['agree']), len(sp['ratio_differs']), len(sp['archive_only']), len(sp['source_only'])))
    for k in ('ratio_differs', 'archive_only', 'source_only'):
        for d in sp[k][:10]:
            print('      %s %s' % (k.replace('_', ' '), json.dumps(d)))
    if res['dates_not_in_source']:
        print('   %d archive dates absent from source (first: %s)' % (len(res['dates_not_in_source']), res['dates_not_in_source'][0]))
    a = res['adjustment_note']
    if a:
        print('   adjustment ratio on %s: archive %.6f, source %.6f (informational; see notes)' % (a['date'], a['archive_adj_ratio'], a['source_adj_ratio']))


def print_options(res):
    print('== options: %d daily files, %s to %s ==' % (res['files'], res['first_date'], res['last_date']))
    for e in res['underlyings']:
        line = '   %-6s %-45s' % (e['underlying'], e['reference'])
        if e['compared']:
            line += ' %d days: %d agree, %d disagree' % (e['days'], e['agree'], len(e['disagree']))
            if e['not_in_source']:
                line += ', %d not in source' % e['not_in_source']
        print(line)
        for d in e['disagree'][:10]:
            print('      %s archive %s  source %s' % (d['date'], d['archive'], d['source']))
        if len(e['disagree']) > 10:
            print('      ... %d more' % (len(e['disagree']) - 10))
        for m in e['multiple_values'][:5]:
            print('      %s carries more than one value: %s' % (m['date'], ', '.join(m['values'])))


def main(argv):
    ap = argparse.ArgumentParser(description='Compare historicaldata.net CSV files with public third-party records.')
    ap.add_argument('--root', required=True, help='folder holding the extracted sample or purchased files')
    ap.add_argument('--sources', default='cboe,yahoo', help='comma-separated: cboe, yahoo (default both)')
    ap.add_argument('--symbols', default='', help='comma-separated symbols to limit the comparison')
    ap.add_argument('--cache', default=None, help='folder to keep the downloaded reference files')
    ap.add_argument('--offline', action='store_true', help='use only references already in --cache')
    ap.add_argument('--json', default=None, help='write the full report to this file')
    ap.add_argument('--version', action='version', version=VERSION)
    args = ap.parse_args(argv)
    sources = {s.strip().lower() for s in args.sources.split(',') if s.strip()}
    unknown = sources - {'cboe', 'yahoo'}
    if unknown:
        print('unknown source: ' + ', '.join(sorted(unknown)), file=sys.stderr)
        return 1
    if args.offline and not args.cache:
        print('--offline requires --cache', file=sys.stderr)
        return 1
    symbols = {s.strip().upper() for s in args.symbols.split(',') if s.strip()}
    if not os.path.isdir(args.root):
        print('not a folder: ' + args.root, file=sys.stderr)
        return 1
    stock_files, option_files = find_files(args.root)
    if symbols:
        stock_files = [(s, p) for s, p in stock_files if s.upper() in symbols]
    errors = []
    report = {'tool': 'reconcile.py', 'version': VERSION, 'run_at': dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
              'root': os.path.abspath(args.root), 'sources': sorted(sources), 'stocks': [], 'options': None, 'errors': errors}
    print('reconcile.py %s  %s  root=%s  sources=%s' % (VERSION, report['run_at'], report['root'], ','.join(report['sources'])))
    print('%d stock daily files, %d options daily files' % (len(stock_files), len(option_files)))
    yahoo = {}
    cboe = {}
    if stock_files and 'yahoo' in sources:
        for sym, path in stock_files:
            rows = read_stock(path)
            if not rows:
                continue
            try:
                yahoo[sym] = load_yahoo(sym, rows[0]['date'], args.cache, args.offline)
            except SourceError as e:
                if '_delisted_' in os.path.basename(path) and ('404' in str(e) or 'not cached' in str(e)):
                    # A delisted security commonly has no history at a live quote source.
                    report['stocks'].append({'symbol': sym, 'file': os.path.relpath(path, args.root), 'rows': len(rows),
                                             'skipped': 'delisted security; the source publishes no history for it'})
                    print('== %s  %d rows ==\n   delisted security; the source publishes no history for it (not compared)' % (sym, len(rows)))
                    continue
                errors.append(str(e))
                print('== %s  %d rows ==\n   source unavailable: %s' % (sym, len(rows), e))
                continue
            res = compare_stock(sym, rows, yahoo[sym])
            res['file'] = os.path.relpath(path, args.root)
            report['stocks'].append(res)
            print_stock(res)
    elif stock_files:
        print('stock files present; yahoo not requested, so no stock comparison')
    if option_files:
        report['options'] = compare_options(option_files, cboe, yahoo, sources, symbols, args.cache, args.offline, errors)
        print_options(report['options'])
    if not stock_files and not option_files:
        print('no recognized stock daily or options files under ' + args.root)
    report['summary'] = summarize(report)
    s = report['summary']
    if s['checks']:
        print('== %d exact-precision checks: %d agree, %d disagree (%.2f%% agree); volumes reported by band, not counted ==' % (
            s['checks'], s['agree'], s['disagree'], s['agreementPct']))
    if errors:
        print('%d reference retrieval problem(s):' % len(errors))
        for e in errors:
            print('   ' + e)
    if args.json:
        with open(args.json, 'w', encoding='utf-8') as fh:
            json.dump(report, fh, indent=1, sort_keys=True)
        print('report written to ' + args.json)
    print('== reconciliation complete; disagreements above identify rows to investigate ==')
    return 2 if errors else 0


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
