"""Three preset retail decision studies built from one frozen Binance spot bundle. No live trading, optimization or personalized recommendation. Usage: python next_retail_studies.py INPUT_SPOT_JSON_GZ OUTPUT_DIR """ from __future__ import annotations import csv import gzip import json import statistics import sys from collections import Counter, defaultdict from datetime import datetime, timezone from pathlib import Path DAY = 86400 VERSION = "retail-decisions-2.0" COST = 0.0015 def iso(ts: int) -> str: return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z") def median(values): return statistics.median(values) if values else None def quantile(values, probability): values = sorted(values) if not values: return None position = (len(values) - 1) * probability lower = int(position) upper = min(lower + 1, len(values) - 1) weight = position - lower return values[lower] * (1 - weight) + values[upper] * weight def xorshift(seed): state = seed & 0xFFFFFFFF while True: state ^= (state << 13) & 0xFFFFFFFF state ^= state >> 17 state ^= (state << 5) & 0xFFFFFFFF state &= 0xFFFFFFFF yield state / 4294967296 def moving_block_ci(values, statistic, block_size=12, iterations=2000, seed=6251806): if not values: return {"low": None, "high": None, "iterations": iterations, "block_size": block_size} rng = xorshift(seed) n = len(values) estimates = [] for _ in range(iterations): sample = [] while len(sample) < n: start = int(next(rng) * n) sample.extend(values[(start + offset) % n] for offset in range(block_size)) estimates.append(statistic(sample[:n])) return {"low": quantile(estimates, .025), "high": quantile(estimates, .975), "iterations": iterations, "block_size": block_size} def complete(series, start, end): return all(ts in series for ts in range(start, end + DAY, DAY)) def max_drawdown(values): peak = values[0] result = 0.0 for value in values: peak = max(peak, value) result = min(result, value / peak - 1) return result def describe(rows, key): values = [row[key] for row in rows if row.get(key) is not None] return { "n": len(values), "median": median(values), "mean": statistics.mean(values) if values else None, "min": min(values) if values else None, "max": max(values) if values else None, "positive_n": sum(value > 0 for value in values), "negative_n": sum(value < 0 for value in values), } def load(path: Path): raw = json.load(gzip.open(path, "rt")) series = { symbol: {int(ts): bar for ts, bar in rows.items()} for symbol, rows in raw["series"].items() } return raw, series def buy(quantity_cash, price, cost=COST): return quantity_cash / (price * (1 + cost)) def dca_path(btc, start, reserve_share, horizon=365, cost=COST, annual_cash_yield=0.0, include_points=False): contribution = 1000.0 schedules = {start + offset * 30 * DAY for offset in range(12)} thresholds = (0.10, 0.20, 0.30) cash = 0.0 quantity = 0.0 contributed = 0.0 units = 0.0 pending = [] costs = 0.0 trade_count = 0 nav_path = [] points = [] deployed_by_threshold = defaultdict(float) daily_cash_factor = (1 + annual_cash_yield) ** (1 / 365) for ts in range(start, start + horizon * DAY, DAY): bar = btc[ts] opening_value = cash + quantity * bar["o"] * (1 - cost) if ts in schedules: pre_nav = opening_value / units if units else 1.0 units += contribution / pre_nav contributed += contribution cash += contribution regular = contribution * (1 - reserve_share) if regular: quantity += buy(regular, bar["o"], cost) cash -= regular costs += regular * cost / (1 + cost) trade_count += 1 if reserve_share: for threshold in thresholds: pending.append({"threshold": threshold, "cash": contribution * reserve_share / 3}) prior = ts - DAY drawdown = None if prior in btc: history = [btc[u]["c"] for u in range(max(min(btc), prior - 364 * DAY), prior + DAY, DAY) if u in btc] if history: drawdown = btc[prior]["c"] / max(history) - 1 if drawdown is not None: eligible = [bucket for bucket in pending if drawdown <= -bucket["threshold"]] for bucket in eligible: amount = bucket["cash"] quantity += buy(amount, bar["o"], cost) cash -= amount costs += amount * cost / (1 + cost) trade_count += 1 deployed_by_threshold[str(bucket["threshold"])] += amount pending.remove(bucket) cash *= daily_cash_factor closing_value = cash + quantity * bar["c"] * (1 - cost) nav = closing_value / units if units else 1.0 nav_path.append(nav) if include_points: points.append({ "time": ts, "equity": closing_value, "contributed": contributed, "nav": nav, "cash": cash, "drawdown_from_365d_high": drawdown, }) terminal = start + horizon * DAY terminal_value = cash + quantity * btc[terminal]["o"] * (1 - cost) costs += quantity * btc[terminal]["o"] * cost result = { "terminal_value": terminal_value, "net_return_on_contributions": terminal_value / contributed - 1, "max_unitized_drawdown": max_drawdown([1.0] + nav_path), "cash_unspent": cash, "cash_unspent_share": cash / contributed, "invested_share": 1 - cash / contributed, "trade_count": trade_count, "modelled_costs": costs, "annual_cash_yield": annual_cash_yield, "deployed_by_threshold": dict(deployed_by_threshold), } if include_points: result["points"] = points return result def dca_study(btc): start_min, end_max = min(btc), max(btc) candidates = [] observations = [] reserve_shares = (0.0, 0.10, 0.25, 0.50) horizons = (365, 730, 1460) for ts in range(start_min, end_max + DAY, DAY): if datetime.fromtimestamp(ts, timezone.utc).day != 1: continue for horizon in horizons: status = "pending" if ts + horizon * DAY > end_max else ("complete" if complete(btc, ts - 365 * DAY, ts + horizon * DAY) else "missing_path") candidates.append({"start": ts, "start_utc": iso(ts), "days": horizon, "status": status}) if status != "complete": continue prior_close = btc[ts-DAY]["c"] prior_high = max(btc[u]["c"] for u in range(ts-365*DAY,ts,DAY)) start_drawdown = prior_close/prior_high-1 start_regime = "near_high" if start_drawdown > -.10 else ("correction_10_30" if start_drawdown > -.30 else "deep_drawdown_30_plus") base = None batch = [] for share in reserve_shares: row = { "id": f"{ts}:{horizon}:{share}:0.0", "start": ts, "start_utc": iso(ts), "days": horizon, "reserve_share": share, "cost": COST, "start_drawdown_365d": start_drawdown, "start_regime": start_regime, **dca_path(btc, ts, share, horizon=horizon), } if share == 0: base = row["net_return_on_contributions"] batch.append(row) for row in batch: row["minus_plain_dca"] = row["net_return_on_contributions"] - base observations.append(row) summary = [] for share in reserve_shares: rows = [row for row in observations if row["reserve_share"] == share and row["days"] == 365] stats = describe(rows, "net_return_on_contributions") summary.append({ "reserve_share": share, **stats, "beat_plain_dca_n": sum(row["minus_plain_dca"] > 0 for row in rows), "median_minus_plain_dca": median([row["minus_plain_dca"] for row in rows]), "median_drawdown": median([row["max_unitized_drawdown"] for row in rows]), "median_cash_unspent_share": median([row["cash_unspent_share"] for row in rows]), "median_trade_count": median([row["trade_count"] for row in rows]), "median_modelled_costs": median([row["modelled_costs"] for row in rows]), }) summary_by_horizon = [] for horizon in horizons: for share in reserve_shares: rows = [row for row in observations if row["reserve_share"] == share and row["days"] == horizon] summary_by_horizon.append({"days":horizon,"reserve_share":share,**describe(rows,"net_return_on_contributions"), "beat_plain_dca_n":sum(row["minus_plain_dca"]>0 for row in rows),"median_minus_plain_dca":median([row["minus_plain_dca"] for row in rows]), "median_drawdown":median([row["max_unitized_drawdown"] for row in rows]),"median_cash_unspent_share":median([row["cash_unspent_share"] for row in rows])}) regime_summary=[] for regime in ("near_high","correction_10_30","deep_drawdown_30_plus"): for share in reserve_shares: rows=[row for row in observations if row["days"]==365 and row["reserve_share"]==share and row["start_regime"]==regime] regime_summary.append({"regime":regime,"reserve_share":share,**describe(rows,"net_return_on_contributions"), "beat_plain_dca_n":sum(row["minus_plain_dca"]>0 for row in rows),"median_excess":median([row["minus_plain_dca"] for row in rows])}) cash_yield_summary=[] one_year_starts=sorted({row["start"] for row in observations if row["days"]==365}) for annual_yield in (.03,.05): for share in (.25,.50): yield_rows=[] for ts in one_year_starts: outcome=dca_path(btc,ts,share,365,annual_cash_yield=annual_yield) base=next(row for row in observations if row["start"]==ts and row["days"]==365 and row["reserve_share"]==0)["net_return_on_contributions"] yield_rows.append(outcome["net_return_on_contributions"]-base) cash_yield_summary.append({"annual_cash_yield":annual_yield,"reserve_share":share,"n":len(yield_rows),"median_excess":median(yield_rows),"beat_plain_dca_n":sum(value>0 for value in yield_rows)}) bootstrap=[] for share in (.10,.25,.50): rows=sorted([row for row in observations if row["days"]==365 and row["reserve_share"]==share],key=lambda row:row["start"]) differences=[row["minus_plain_dca"] for row in rows] bootstrap.append({"reserve_share":share,"statistic":"median paired excess",**moving_block_ci(differences,median,12,2000,6251806+int(share*100))}) complete_starts = sorted({row["start"] for row in observations if row["days"]==365}) anchor_starts = [] previous = None for ts in complete_starts: if previous is None or ts >= previous + 365 * DAY: anchor_starts.append(ts) previous = ts nonoverlap = [] for share in reserve_shares: rows = [row for row in observations if row["days"]==365 and row["reserve_share"] == share and row["start"] in anchor_starts] nonoverlap.append({"reserve_share": share, **describe(rows, "net_return_on_contributions")}) return { "version": VERSION, "contract": { "budget": "12 contributions of USD 1,000, 30 UTC dates apart", "reserve_buckets": "Each reserved contribution is split equally across 10%, 20% and 30% drawdown triggers from the trailing 365-day closing high.", "execution": "A trigger known at a completed UTC close buys at the next UTC open; regular contribution capital buys at its scheduled UTC open.", "untriggered": "Untriggered reserve remains cash with zero yield through the common terminal open.", }, "candidates": candidates, "observations": observations, "summary": summary, "summary_by_horizon":summary_by_horizon, "regime_summary":regime_summary, "cash_yield_summary":cash_yield_summary, "bootstrap":bootstrap, "nonoverlap_365": nonoverlap, } def alt_path(alt, btc, entry, days, cost=COST): end = entry + days * DAY alt_entry = alt[entry]["o"] btc_entry = btc[entry]["o"] alt_end = alt[end]["o"] btc_end = btc[end]["o"] hold = alt_end / alt_entry * (1 - cost) - 1 capital_after_sale = 1 - cost btc_quantity = capital_after_sale / (btc_entry * (1 + cost)) rotate = btc_quantity * btc_end * (1 - cost) - 1 cash = -cost half_cash = .5 * (hold + 1) + .5 * (cash + 1) - 1 half_btc = .5 * (hold + 1) + .5 * (rotate + 1) - 1 hold_path=[alt[entry+offset*DAY]["c"]/alt_entry*(1-cost)-1 for offset in range(days) if entry+offset*DAY in alt] return { "hold_return": hold, "rotate_btc_return": rotate, "cash_return": cash, "rotate_minus_hold": rotate - hold, "cash_minus_hold": cash - hold, "half_cash_return":half_cash, "half_btc_return":half_btc, "hold_mfe":max(hold_path) if hold_path else None, "hold_mae":min(hold_path) if hold_path else None, "hold_worst_day":hold_path.index(min(hold_path)) if hold_path else None, } def altcoin_profit_study(series, universe): btc = series["BTCUSDT"] allowed = {row["symbol"] for row in universe if row.get("excluded_reason") is None} candidates = [] observations = [] thresholds = (0.50, 1.0, 2.0) horizons = (30, 90, 180) cutoff = max(btc) for symbol in sorted(allowed): if symbol == "BTCUSDT" or symbol not in series: continue alt = series[symbol] if len(alt) < 121: continue last_event = {threshold: None for threshold in thresholds} for ts in sorted(alt): if ts - DAY not in alt: continue window = [alt[u] for u in range(ts - 90 * DAY, ts, DAY) if u in alt] volume_window = [alt[u]["qv"] for u in range(ts - 30 * DAY, ts, DAY) if u in alt] if len(window) != 90 or len(volume_window) != 30: continue avg_volume = statistics.mean(volume_window) if avg_volume < 1_000_000: continue low = min(bar["c"] for bar in window) previous_multiple = alt[ts - DAY]["c"] / low - 1 current_multiple = alt[ts]["c"] / low - 1 volume_ratio = alt[ts]["qv"] / avg_volume if avg_volume else None listing_age_days = (ts - min(alt)) // DAY prior_asset_high = max(alt[u]["c"] for u in alt if u < ts) distance_from_prior_high = alt[ts]["c"] / prior_asset_high - 1 btc_90d_return = btc[ts]["c"] / btc[ts - 90 * DAY]["c"] - 1 if ts in btc and ts - 90 * DAY in btc else None btc_prior_high = max((btc[u]["c"] for u in range(ts - 365 * DAY, ts, DAY) if u in btc), default=None) btc_drawdown = btc[ts]["c"] / btc_prior_high - 1 if btc_prior_high and ts in btc else None for threshold in thresholds: if not previous_multiple < threshold <= current_multiple: continue if last_event[threshold] is not None and ts - last_event[threshold] < 90 * DAY: continue last_event[threshold] = ts entry = ts + DAY for days in horizons: end = entry + days * DAY if end > cutoff: status = "pending" elif entry not in alt or entry not in btc or end not in alt or end not in btc: status = "missing_exit" elif not complete(alt, entry, end) or not complete(btc, entry, end): status = "missing_path" else: status = "complete" candidate = { "candidate_id": f"{symbol}:{ts}:{threshold}:{days}", "symbol": symbol, "signal": ts, "signal_utc": iso(ts), "entry": entry, "entry_utc": iso(entry), "threshold": threshold, "days": days, "reference_90d_low": low, "signal_close": alt[ts]["c"], "rise_from_reference_low": current_multiple, "trailing_30d_avg_quote_volume": avg_volume, "signal_volume_ratio":volume_ratio, "listing_age_days":listing_age_days, "distance_from_prior_high":distance_from_prior_high, "btc_90d_return":btc_90d_return, "btc_drawdown_365d":btc_drawdown, "status": status, } candidates.append(candidate) if status == "complete": observations.append({"id": candidate["candidate_id"], **candidate, "cost": COST, **alt_path(alt, btc, entry, days)}) summary = [] for threshold in thresholds: for days in horizons: rows = [row for row in observations if row["threshold"] == threshold and row["days"] == days] summary.append({ "threshold": threshold, "days": days, "symbols": len({row["symbol"] for row in rows}), "events": len(rows), "hold_median": median([row["hold_return"] for row in rows]), "rotate_median": median([row["rotate_btc_return"] for row in rows]), "cash_median": median([row["cash_return"] for row in rows]), "rotate_beat_hold_n": sum(row["rotate_minus_hold"] > 0 for row in rows), "cash_beat_hold_n": sum(row["cash_minus_hold"] > 0 for row in rows), "hold_positive_n": sum(row["hold_return"] > 0 for row in rows), "rotate_positive_n": sum(row["rotate_btc_return"] > 0 for row in rows), "half_cash_median":median([row["half_cash_return"] for row in rows]), "half_btc_median":median([row["half_btc_return"] for row in rows]), "hold_mfe_median":median([row["hold_mfe"] for row in rows]), "hold_mae_median":median([row["hold_mae"] for row in rows]), }) main = [row for row in observations if row["threshold"] == 1.0 and row["days"] == 90] by_symbol = [] for symbol, rows in sorted(defaultdict(list, {symbol: [row for row in main if row["symbol"] == symbol] for symbol in {row["symbol"] for row in main}}).items()): by_symbol.append({ "symbol": symbol, "n": len(rows), "hold_median": median([row["hold_return"] for row in rows]), "rotate_median": median([row["rotate_btc_return"] for row in rows]), "rotate_beat_hold_n": sum(row["rotate_minus_hold"] > 0 for row in rows), }) first_events=[] for symbol in sorted({row["symbol"] for row in main}): rows=sorted([row for row in main if row["symbol"]==symbol],key=lambda row:row["signal"]) if rows:first_events.append(rows[0]) symbol_medians=[] for symbol in sorted({row["symbol"] for row in main}): rows=[row for row in main if row["symbol"]==symbol] symbol_medians.append({"symbol":symbol,"hold":median([row["hold_return"] for row in rows]),"rotate":median([row["rotate_btc_return"] for row in rows]),"paired":median([row["rotate_minus_hold"] for row in rows])}) robustness={ "first_event_only":{"n":len(first_events),"hold_median":median([row["hold_return"] for row in first_events]),"rotate_median":median([row["rotate_btc_return"] for row in first_events]),"rotate_beat_hold_n":sum(row["rotate_minus_hold"]>0 for row in first_events)}, "equal_symbol_weight":{"symbols":len(symbol_medians),"hold_median_of_symbol_medians":median([row["hold"] for row in symbol_medians]),"rotate_median_of_symbol_medians":median([row["rotate"] for row in symbol_medians]),"paired_median_of_symbol_medians":median([row["paired"] for row in symbol_medians])}, } strata=[] rules=[ ("btc_90d_trend","BTC 90d up",lambda row:row["btc_90d_return"] is not None and row["btc_90d_return"]>=0), ("btc_90d_trend","BTC 90d down",lambda row:row["btc_90d_return"] is not None and row["btc_90d_return"]<0), ("volume_confirmation","volume ratio >=2x",lambda row:row["signal_volume_ratio"] is not None and row["signal_volume_ratio"]>=2), ("volume_confirmation","volume ratio <2x",lambda row:row["signal_volume_ratio"] is not None and row["signal_volume_ratio"]<2), ("listing_age","listed <180d",lambda row:row["listing_age_days"]<180), ("listing_age","listed >=180d",lambda row:row["listing_age_days"]>=180), ("prior_high","within 10% of prior high",lambda row:row["distance_from_prior_high"]>=-.10), ("prior_high","more than 10% below prior high",lambda row:row["distance_from_prior_high"]<-.10), ] for dimension,label,predicate in rules: rows=[row for row in main if predicate(row)] strata.append({"dimension":dimension,"label":label,"n":len(rows),"symbols":len({row["symbol"] for row in rows}), "hold_median":median([row["hold_return"] for row in rows]),"rotate_median":median([row["rotate_btc_return"] for row in rows]), "rotate_beat_hold_n":sum(row["rotate_minus_hold"]>0 for row in rows),"hold_mfe_median":median([row["hold_mfe"] for row in rows]),"hold_mae_median":median([row["hold_mae"] for row in rows])}) cluster_values=[row["paired"] for row in symbol_medians] cluster_ci=moving_block_ci(cluster_values,median,1,3000,6251907) return { "version": VERSION, "contract": { "event": "A completed daily close first crosses 100% above the preceding 90 completed daily closes' minimum; 50% and 200% are preset sensitivity thresholds.", "cooldown": "90 UTC dates per symbol and threshold.", "liquidity": "Trailing 30 completed daily quote-volume mean at least USD 1 million.", "decisions": "At next UTC open, continue holding the existing altcoin, sell and buy BTC, or sell to zero-yield cash.", }, "universe_count": len(allowed), "candidates": candidates, "observations": observations, "summary": summary, "main_by_symbol": sorted(by_symbol, key=lambda row: (-row["n"], row["symbol"])), "robustness":robustness, "strata":strata, "symbol_cluster_ci":{"statistic":"median of symbol-level median rotate-minus-hold differences",**cluster_ci}, "status_counts": dict(Counter(row["status"] for row in candidates)), } def holding_path(btc, start, days, cost=COST): quantity = buy(1.0, btc[start]["o"], cost) terminal = quantity * btc[start + days * DAY]["o"] * (1 - cost) values = [1.0] recovery_day = None below_days=[] for offset in range(days): ts = start + offset * DAY value = quantity * btc[ts]["c"] * (1 - cost) values.append(value) if recovery_day is None and value >= 1.0: recovery_day = offset if value<1.0:below_days.append(offset) values.append(terminal) if terminal<1.0:below_days.append(days) permanent_break_even=(max(below_days)+1) if terminal>=1 and below_days and max(below_days)=1 and not below_days else None) return { "net_return": terminal - 1, "max_drawdown": max_drawdown(values), "first_break_even_day": recovery_day, "recovered_within_window": recovery_day is not None, "permanent_break_even_day":permanent_break_even, "underwater_day_share":len(set(below_days))/(days+1), "mfe":max(values)-1, "mae":min(values)-1, } def bitcoin_holding_study(btc, sentiment=None, coinbase=None): horizons = (30, 90, 180, 365, 730, 1460) first, last = min(btc), max(btc) observations = [] pending = [] for days in horizons: for ts in range(first, last + DAY, DAY): end = ts + days * DAY if end > last: pending.append({"start": ts, "start_utc": iso(ts), "days": days, "status": "pending"}) continue if not complete(btc, ts, end): pending.append({"start": ts, "start_utc": iso(ts), "days": days, "status": "missing_path"}) continue observations.append({ "id": f"{ts}:{days}", "start": ts, "start_utc": iso(ts), "days": days, "cost": COST, **holding_path(btc, ts, days), }) row=observations[-1] prior=ts-DAY history=[btc[u]["c"] for u in range(ts-365*DAY,ts,DAY) if u in btc] if prior in btc and len(history)>=300: dd=btc[prior]["c"]/max(history)-1 row["entry_drawdown_365d"]=dd row["entry_zone"]="within_5pct_high" if dd>=-.05 else ("drawdown_5_20" if dd>=-.20 else ("drawdown_20_40" if dd>=-.40 else "drawdown_40_plus")) else: row["entry_drawdown_365d"]=None;row["entry_zone"]="insufficient_prior_history" value=sentiment.get(prior) if sentiment else None row["fear_greed_value"]=value row["sentiment_zone"]=("extreme_fear" if value is not None and value<=24 else "fear" if value is not None and value<=44 else "neutral" if value is not None and value<=55 else "greed" if value is not None and value<=74 else "extreme_greed" if value is not None else "unavailable") summary = [] nonoverlap = [] for days in horizons: rows = [row for row in observations if row["days"] == days] summary.append({ "days": days, **describe(rows, "net_return"), "loss_n": sum(row["net_return"] < 0 for row in rows), "median_drawdown": median([row["max_drawdown"] for row in rows]), "recovered_n": sum(row["recovered_within_window"] for row in rows), "median_break_even_day": median([row["first_break_even_day"] for row in rows if row["first_break_even_day"] is not None]), "median_permanent_break_even_day":median([row["permanent_break_even_day"] for row in rows if row["permanent_break_even_day"] is not None]), "median_underwater_share":median([row["underwater_day_share"] for row in rows]), "p10":quantile([row["net_return"] for row in rows],.10),"p25":quantile([row["net_return"] for row in rows],.25), "p75":quantile([row["net_return"] for row in rows],.75),"p90":quantile([row["net_return"] for row in rows],.90), "median_mfe":median([row["mfe"] for row in rows]),"median_mae":median([row["mae"] for row in rows]), }) anchors = [] previous = None for ts in sorted(row["start"] for row in rows): if previous is None or ts >= previous + days * DAY: anchors.append(ts) previous = ts anchor_rows = [row for row in rows if row["start"] in anchors] nonoverlap.append({"days": days, **describe(anchor_rows, "net_return"), "loss_n": sum(row["net_return"] < 0 for row in anchor_rows)}) yearly = [] for year in sorted({datetime.fromtimestamp(row["start"], timezone.utc).year for row in observations if row["days"] in (365, 1460)}): for days in (365, 1460): rows = [row for row in observations if row["days"] == days and datetime.fromtimestamp(row["start"], timezone.utc).year == year] if rows: yearly.append({"year": year, "days": days, **describe(rows, "net_return"), "loss_n": sum(row["net_return"] < 0 for row in rows)}) holding_curve=[] for days in range(30,1461,30): values=[] for ts in range(first,last-days*DAY+DAY,DAY): if ts in btc and ts+days*DAY in btc: quantity=1/(btc[ts]["o"]*(1+COST));values.append(quantity*btc[ts+days*DAY]["o"]*(1-COST)-1) holding_curve.append({"days":days,"n":len(values),"loss_n":sum(value<0 for value in values),"loss_rate":sum(value<0 for value in values)/len(values), "median":median(values),"p10":quantile(values,.1),"p25":quantile(values,.25),"p75":quantile(values,.75),"p90":quantile(values,.9)}) entry_strata=[] for days in (365,730,1460): for dimension,values in (("entry_zone",("within_5pct_high","drawdown_5_20","drawdown_20_40","drawdown_40_plus")), ("sentiment_zone",("extreme_fear","fear","neutral","greed","extreme_greed"))): for value in values: rows=[row for row in observations if row["days"]==days and row.get(dimension)==value] if rows:entry_strata.append({"days":days,"dimension":dimension,"value":value,**describe(rows,"net_return"),"loss_n":sum(row["net_return"]<0 for row in rows), "median_drawdown":median([row["max_drawdown"] for row in rows]),"median_underwater_share":median([row["underwater_day_share"] for row in rows])}) bootstrap=[] for days in horizons: rows=sorted([row for row in observations if row["days"]==days],key=lambda row:row["start"]) values=[row["net_return"] for row in rows] block=max(30,min(365,days)) bootstrap.append({"days":days,"median_ci":moving_block_ci(values,median,block,1000,6252000+days), "loss_rate_ci":moving_block_ci(values,lambda sample:sum(value<0 for value in sample)/len(sample),block,1000,6253000+days)}) cross_venue=[] if coinbase: for days in horizons: matched=[] for row in observations: if row["days"]!=days:continue start=row["start"];end=start+days*DAY if start in coinbase and end in coinbase: quantity=1/(coinbase[start]["o"]*(1+COST));cb_return=quantity*coinbase[end]["o"]*(1-COST)-1 matched.append((row["net_return"],cb_return)) cross_venue.append({"days":days,"matched_n":len(matched),"binance_median":median([row[0] for row in matched]),"coinbase_median":median([row[1] for row in matched]), "binance_loss_n":sum(row[0]<0 for row in matched),"coinbase_loss_n":sum(row[1]<0 for row in matched),"median_return_difference":median([row[1]-row[0] for row in matched])}) return { "version": VERSION, "contract": { "entry_exit": "Buy at one completed Binance BTCUSDT UTC daily open and hypothetically sell at the UTC open exactly N dates later.", "cost": "0.15% of notional on both entry and exit.", "windows": list(horizons), "overlap": "All daily starts are descriptive and highly overlapping; a greedy non-overlapping sensitivity is published separately.", }, "observations": observations, "candidates": pending, "summary": summary, "nonoverlap": nonoverlap, "yearly": yearly, "holding_curve":holding_curve, "entry_strata":entry_strata, "bootstrap":bootstrap, "cross_venue":cross_venue, } def write_csv(path, rows): fields = list(dict.fromkeys(key for row in rows for key in row)) with path.open("w", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=fields) writer.writeheader() for row in rows: writer.writerow({key: json.dumps(value, separators=(",", ":")) if isinstance(value, (dict, list)) else value for key, value in row.items()}) def main(input_path: Path, output: Path, btc_input_path: Path | None = None, coinbase_input_path: Path | None = None): output.mkdir(parents=True, exist_ok=True) raw, series = load(input_path) btc = series["BTCUSDT"] sentiment={} if btc_input_path is not None: frozen = json.load(gzip.open(btc_input_path, "rt")) btc = { int(row["time"]): { "o": row["open"], "h": row["high"], "l": row["low"], "c": row["close"], "qv": row["quote_volume"], } for row in frozen["btc"] } sentiment={int(row["time"]):int(row["value"]) for row in frozen.get("fear_greed",[])} coinbase=None if coinbase_input_path is not None: cb=json.loads(coinbase_input_path.read_text()) coinbase={int(row["time"]):{"o":row["open"],"c":row["close"]} for row in cb["rows"]} studies = { "dca-dip": dca_study(btc), "altcoin-profit": altcoin_profit_study(series, raw["universe"]), "bitcoin-holding": bitcoin_holding_study(btc,sentiment,coinbase), } for name, study in studies.items(): (output / f"{name}.json").write_text(json.dumps(study, separators=(",", ":"), allow_nan=False) + "\n") write_csv(output / f"{name}-observations.csv", study["observations"]) write_csv(output / f"{name}-summary.csv", study["summary"]) write_csv(output / f"{name}-candidates.csv", study.get("candidates", [])) print(name, len(study["observations"]), "observations") if __name__ == "__main__": main(Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3]) if len(sys.argv) > 3 else None, Path(sys.argv[4]) if len(sys.argv) > 4 else None)