"""Research-only spot grid proxy; no order execution or network access. python grid_bot_study.py INPUT_DIRECTORY OUTPUT.json Same frozen CSV inputs as stop_loss_study.py; standard library only. """ import csv,json,math,random,statistics,sys from pathlib import Path from collections import Counter,defaultdict from datetime import datetime,timezone DAY=86400 COST=.0015 VERSION='spot-grid-accounting-1.0' def iso(t):return datetime.fromtimestamp(t,timezone.utc).isoformat().replace('+00:00','Z') def quantile(xs,p): xs=sorted(xs) if not xs:return None pos=(len(xs)-1)*p;i=int(pos) return xs[i]+(xs[min(i+1,len(xs)-1)]-xs[i])*(pos-i) def load(directory,symbol): with (Path(directory)/(symbol+'.csv')).open() as f: return {int(r['time']):{k:float(v) for k,v in r.items() if k!='time'} for r in csv.DictReader(f)} def grid(bars,t,lower,upper,grids=20,cost=COST,fill_mode='close',details=False,days=30): entry=bars[t]['o'];end=t+days*DAY;step=(upper-lower)/grids slots=[dict(i=i,lower=lower+i*step,upper=lower+(i+1)*step,held=lower+i*step>=entry) for i in range(grids)] q=1/sum((entry if s['held'] else s['lower'])*(1+cost) for s in slots) initial_quantity=0.;initial_cash=0.;fees=0. for s in slots: s.update(cash=0. if s['held'] else q*s['lower']*(1+cost),basis=q*entry*(1+cost) if s['held'] else None,seed=s['held']) if s['held']:initial_quantity+=q;fees+=q*entry*cost initial_cash+=s['cash'] initial=[dict(s) for s in slots] realized=0.;seed_realized=0.;trades=[];points=[];cycles=0;count=0;outside=0;shortfall=0;peak=1.;maxdd=0.;min_inventory=initial_quantity;max_inventory=initial_quantity quantity=initial_quantity;cash=initial_cash for u in range(t,end,300): b=bars[u] for s in slots: if s['held']: if (b['c'] if fill_mode=='close' else b['h'])>=s['upper']: proceeds=q*s['upper']*(1-cost);profit=proceeds-s['basis'] cash+=proceeds;quantity-=q;s['cash']+=proceeds if s['seed']:seed_realized+=profit else:realized+=profit;cycles+=1 fees+=q*s['upper']*cost;count+=1 if details:trades.append(dict(bar_time=iso(u),slot=s['i'],side='sell',price=s['upper'],quantity=q,cost=q*s['upper']*cost,pnl=profit,seed=s['seed'])) s.update(held=False,basis=None,seed=False) else: if (b['c'] if fill_mode=='close' else b['l'])<=s['lower']: debit=q*s['lower']*(1+cost) if s['cash']+1e-12 < debit: shortfall+=1;continue s['cash']-=debit;cash-=debit;quantity+=q;fees+=q*s['lower']*cost;count+=1 s.update(held=True,basis=debit,seed=False) if details:trades.append(dict(bar_time=iso(u),slot=s['i'],side='buy',price=s['lower'],quantity=q,cost=q*s['lower']*cost,pnl=None,seed=False)) equity=cash+quantity*b['c']*(1-cost) peak=max(peak,equity);maxdd=min(maxdd,equity/peak-1) outside+=int(b['c']upper) min_inventory=min(min_inventory,quantity);max_inventory=max(max_inventory,quantity) if details and (u-t)%3600==3300: points.append(dict(time=u+300,price=b['c'],equity=equity,grid_profit=realized, buy_hold=b['c']*(1-cost)/(entry*(1+cost)), passive=initial_cash+initial_quantity*b['c']*(1-cost), inventory_value=quantity*b['c'],cash=cash)) terminal=bars[end]['o'];exit_cost=quantity*terminal*cost value=cash+quantity*terminal*(1-cost) inventory=sum(q*terminal*(1-cost)-s['basis'] for s in slots if s['held']) total=value-1;passive=initial_cash+initial_quantity*terminal*(1-cost)-1;hold=terminal*(1-cost)/(entry*(1+cost))-1 assert abs(total-(realized+seed_realized+inventory))<1e-10 assert abs(cash-sum(s['cash'] for s in slots))<1e-10 assert abs(quantity-q*sum(s['held'] for s in slots))<1e-10 maxdd=min(maxdd,value/peak-1) out=dict(grids=grids,cost_per_side=cost,fill_mode=fill_mode,lower=lower,upper=upper,entry_price=entry,terminal_price=terminal,quantity_per_slot=q, initial_quantity=initial_quantity,initial_cash=initial_cash,initial_coin_notional=initial_quantity*entry, ending_quantity=max(0,quantity),ending_cash=cash,grid_cycle_pnl=realized,seed_realized_pnl=seed_realized,inventory_pnl=inventory,total_return=total, buy_hold_return=hold,passive_return=passive,difference_vs_hold=total-hold,difference_vs_passive=total-passive, fees_paid=fees,hypothetical_exit_cost=exit_cost,cycles=cycles,trade_count=count,shortfall_bar_slots=shortfall, outside_fraction=outside/(days*288),max_drawdown=maxdd,price_return=terminal/entry-1, min_quantity=max(0,min_inventory),max_quantity=max_inventory) if details: points.insert(0,dict(time=t,price=entry,equity=initial_cash+initial_quantity*entry*(1-cost),grid_profit=0.,buy_hold=(1-cost)/(1+cost),passive=initial_cash+initial_quantity*entry*(1-cost),inventory_value=initial_quantity*entry,cash=initial_cash)) # Last 5m close and terminal open share a boundary; retain only actual terminal endpoint in chart path. points[-1]=dict(time=end,price=terminal,equity=value,grid_profit=realized,buy_hold=hold+1,passive=passive+1,inventory_value=quantity*terminal,cash=cash) out.update(trades=trades,points=points,initial_slots=initial,final_slots=slots) return out def block_ci(rows,key): groups=defaultdict(list) for r in rows:groups[r['entry_time']].append(r['policies'][key]['difference_vs_passive']) weeks=[groups[k] for k in sorted(groups)] if len(rows)<20 or len(weeks)<12:return None rng=random.Random(20260919);estimates=[] for _ in range(1000): chosen=[] while len(chosen)0] mean=lambda k:statistics.mean(v[k] for v in values) if values else None return dict(n=len(values),positive_grid_n=len(positive_grid),positive_grid_negative_total_n=sum(v['total_return']<0 for v in positive_grid), positive_total_n=sum(v['total_return']>0 for v in values),beats_hold_n=sum(v['difference_vs_hold']>1e-12 for v in values), beats_passive_n=sum(v['difference_vs_passive']>1e-12 for v in values), median_return=quantile([v['total_return'] for v in values],.5),mean_return=mean('total_return'), mean_difference_vs_passive=mean('difference_vs_passive'),difference_ci95=block_ci(rows,key) if interval else None, median_grid_pnl=quantile([v['grid_cycle_pnl'] for v in values],.5), median_hold=quantile([v['buy_hold_return'] for v in values],.5),median_passive=quantile([v['passive_return'] for v in values],.5), mean_fees_paid=mean('fees_paid'),mean_exit_cost=mean('hypothetical_exit_cost'),mean_outside_fraction=mean('outside_fraction'), median_drawdown=quantile([v['max_drawdown'] for v in values],.5),mean_cycles=mean('cycles')) def run(directory): m=json.loads((Path(directory)/'manifest.json').read_text());all_rows=[] configs={'main':(20,.0015,'close'),'grids_10':(10,.0015,'close'),'grids_40':(40,.0015,'close'),'cost_low':(20,.0005,'close'),'cost_high':(20,.003,'close'),'wick_touch':(20,.0015,'wick')} for symbol in ['BTCUSDT','ETHUSDT','SOLUSDT']: bars=load(directory,symbol) for t in range(m['start']+30*DAY,m['end_exclusive'],DAY): if datetime.fromtimestamp(t,timezone.utc).weekday()!=0:continue row=dict(id=f'{symbol}:{t}',symbol=symbol,entry_time=t,entry_utc=iso(t),exit_utc=iso(t+30*DAY)) if t+30*DAY>=m['end_exclusive']:row['status']='pending' elif any(u not in bars for u in range(t-30*DAY,t+30*DAY+300,300)):row['status']='missing' else: history=[bars[u] for u in range(t-30*DAY,t,300)] lo=min(b['l'] for b in history);hi=max(b['h'] for b in history);entry=bars[t]['o'] row.update(lower=lo,upper=hi) if not lo.1 else ('falling_lt_minus10' if ret<-.1 else 'endpoint_within10') all_rows.append(row) rows=[r for r in all_rows if r['status']=='complete'] return dict(version=VERSION,input_start=iso(m['start']),input_end_exclusive=iso(m['end_exclusive']),input_observed_at=m['captured_at'], configs=configs,candidates=all_rows,status_counts=dict(Counter(r['status'] for r in all_rows)),summary=summarize(rows,interval=True), sensitivity={k:summarize(rows,k,True) for k in configs if k!='main'}, by_symbol={s:summarize([r for r in rows if r['symbol']==s]) for s in ['BTCUSDT','ETHUSDT','SOLUSDT']}, by_outcome_regime={k:summarize([r for r in rows if r['outcome_regime']==k]) for k in ['rising_gt10','falling_lt_minus10','endpoint_within10']}, by_prior_trend={k:summarize([r for r in rows if (r['prior30_price_return']>0)==(k=='positive')]) for k in ['positive','nonpositive']}) if __name__=='__main__': r=run(sys.argv[1]);Path(sys.argv[2]).write_text(json.dumps(r,separators=(',',':'))+'\n') print(json.dumps({k:r[k] for k in ['status_counts','summary','sensitivity','by_outcome_regime']},indent=2))