کد بکتست قابل بازتولید EMA 50/200 — BTC/USDT
کد واقعی First Lab Run نکسیتو برای دریافت داده رسمی Binance Public Data Archive، اعتبارسنجی CHECKSUM، فریز Dataset، محاسبه EMA 50/200، اجرای بکتست Long-Only و تولید Result JSON قابل Verify در Quant Lab.
vNXQR-1.1.0دانلود کد ↓
Python 3.10+ Standard library only No third-party Python packages required
PythonNXQR-1.1.0
#!/usr/bin/env python3
"""Nexito Quant Runner — EMA 50/200 BTC/USDT reproducible baseline.
No third-party packages are required. The runner downloads public Binance Spot
1d klines, freezes the normalized dataset to CSV, computes the baseline exactly
as documented in Nexito, and emits an import-ready JSON result.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import hmac
import io
import json
import math
import os
import statistics
import sys
import time
import urllib.parse
import urllib.request
import urllib.error
import zipfile
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Iterable, Optional
RUNNER_VERSION = "NXQR-1.1.0"
RESULT_SCHEMA = "nexito.quant.backtest-result.v1"
DAY_MS = 86_400_000
BASE_URLS = [
"https://data-api.binance.vision",
"https://api.binance.com",
"https://api1.binance.com",
]
ARCHIVE_BASE = "https://data.binance.vision/data/spot"
CSV_FIELDS = [
"open_time_ms", "open", "high", "low", "close", "volume", "close_time_ms",
"quote_asset_volume", "number_of_trades", "taker_buy_base_volume",
"taker_buy_quote_volume",
]
@dataclass(frozen=True)
class Bar:
open_time_ms: int
open: float
high: float
low: float
close: float
volume: float
close_time_ms: int
quote_asset_volume: float
number_of_trades: int
taker_buy_base_volume: float
taker_buy_quote_volume: float
@property
def day(self) -> date:
return datetime.fromtimestamp(self.open_time_ms / 1000, tz=timezone.utc).date()
def iso_z_from_ms(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def canonical_json_bytes(obj: object) -> bytes:
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def api_get_json(base: str, path: str, params: dict, timeout: int = 25):
url = base.rstrip("/") + path + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": f"NexitoQuantRunner/{RUNNER_VERSION}"})
with urllib.request.urlopen(req, timeout=timeout) as response:
if response.status != 200:
raise RuntimeError(f"HTTP {response.status} from {base}")
return json.loads(response.read().decode("utf-8"))
class ArchiveNotFound(RuntimeError):
pass
def http_get_bytes(url: str, timeout: int = 35, attempts: int = 3) -> bytes:
last_error: Optional[Exception] = None
for attempt in range(1, attempts + 1):
try:
req = urllib.request.Request(url, headers={
"User-Agent": f"NexitoQuantRunner/{RUNNER_VERSION}",
"Accept": "*/*",
})
with urllib.request.urlopen(req, timeout=timeout) as response:
if response.status != 200:
raise RuntimeError(f"HTTP {response.status} for {url}")
return response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise ArchiveNotFound(f"Archive not found: {url}") from exc
last_error = exc
except Exception as exc:
last_error = exc
if attempt < attempts:
time.sleep(min(4.0, 0.75 * (2 ** (attempt - 1))))
raise RuntimeError(f"Download failed after {attempts} attempt(s): {url} | {last_error}")
def normalize_archive_timestamp(value: str | int) -> int:
raw = int(value)
# Binance Spot archive timestamps are milliseconds before 2025-01-01 and
# microseconds from 2025-01-01 onward. Nexito freezes milliseconds.
if raw >= 100_000_000_000_000:
return raw // 1000
return raw
def parse_archive_zip(data: bytes, expected_filename: str) -> list[Bar]:
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile as exc:
raise RuntimeError(f"Invalid Binance archive ZIP: {expected_filename}") from exc
names = [n for n in zf.namelist() if not n.endswith('/')]
csv_names = [n for n in names if n.lower().endswith('.csv')]
if len(csv_names) != 1:
raise RuntimeError(f"Expected exactly one CSV in {expected_filename}, found {len(csv_names)}")
raw_csv = zf.read(csv_names[0]).decode('utf-8-sig')
bars: list[Bar] = []
for row_no, row in enumerate(csv.reader(io.StringIO(raw_csv)), start=1):
if not row:
continue
if row_no == 1 and not row[0].strip().lstrip('-').isdigit():
continue
if len(row) < 11:
raise RuntimeError(f"Malformed Binance archive row {row_no} in {expected_filename}")
bars.append(Bar(
open_time_ms=normalize_archive_timestamp(row[0]),
open=float(row[1]), high=float(row[2]), low=float(row[3]), close=float(row[4]),
volume=float(row[5]), close_time_ms=normalize_archive_timestamp(row[6]),
quote_asset_volume=float(row[7]), number_of_trades=int(row[8]),
taker_buy_base_volume=float(row[9]), taker_buy_quote_volume=float(row[10]),
))
return bars
def verify_archive_checksum(zip_bytes: bytes, checksum_bytes: bytes, filename: str) -> str:
text = checksum_bytes.decode('utf-8', errors='replace').strip()
token = text.split()[0].lower() if text else ''
if not (len(token) == 64 and all(c in '0123456789abcdef' for c in token)):
raise RuntimeError(f"Invalid Binance CHECKSUM for {filename}")
actual = sha256_bytes(zip_bytes)
if not hmac.compare_digest(token, actual):
raise RuntimeError(f"Binance CHECKSUM mismatch for {filename}")
return actual
def month_floor(d: date) -> date:
return date(d.year, d.month, 1)
def next_month(d: date) -> date:
return date(d.year + (1 if d.month == 12 else 0), 1 if d.month == 12 else d.month + 1, 1)
def month_last_day(d: date) -> date:
return next_month(month_floor(d)) - timedelta(days=1)
def archive_file_urls(symbol: str, interval: str, kind: str, stamp: str, archive_base: str) -> tuple[str, str, str]:
if kind == 'monthly':
filename = f"{symbol}-{interval}-{stamp}.zip"
url = f"{archive_base.rstrip('/')}/monthly/klines/{symbol}/{interval}/{filename}"
elif kind == 'daily':
filename = f"{symbol}-{interval}-{stamp}.zip"
url = f"{archive_base.rstrip('/')}/daily/klines/{symbol}/{interval}/{filename}"
else:
raise ValueError('unknown archive kind')
return filename, url, url + '.CHECKSUM'
def download_archive_file(symbol: str, interval: str, kind: str, stamp: str, archive_base: str) -> tuple[list[Bar], dict]:
filename, zip_url, checksum_url = archive_file_urls(symbol, interval, kind, stamp, archive_base)
print(f"[archive] {kind:7s} {stamp} · download + checksum", flush=True)
zip_bytes = http_get_bytes(zip_url)
checksum_bytes = http_get_bytes(checksum_url)
zip_sha = verify_archive_checksum(zip_bytes, checksum_bytes, filename)
bars = parse_archive_zip(zip_bytes, filename)
return bars, {
'kind': kind,
'file': filename,
'sha256': zip_sha,
'rows': len(bars),
'url': zip_url,
}
def fetch_klines_archive(symbol: str, interval: str, source_start: date, end: date, archive_base: str = ARCHIVE_BASE) -> tuple[list[Bar], str, list[dict]]:
if interval != '1d':
raise RuntimeError('Archive mode for this baseline currently supports interval=1d only')
if end < source_start:
raise RuntimeError('Archive end is before source start')
all_bars: list[Bar] = []
manifest: list[dict] = []
cursor = month_floor(source_start)
end_month = month_floor(end)
while cursor < end_month:
stamp = cursor.strftime('%Y-%m')
try:
bars, meta = download_archive_file(symbol, interval, 'monthly', stamp, archive_base)
all_bars.extend(bars)
manifest.append(meta)
except ArchiveNotFound:
# A missing monthly archive is recovered from official daily archives.
day = max(cursor, source_start)
last = min(month_last_day(cursor), end)
recovered = 0
while day <= last:
try:
bars, meta = download_archive_file(symbol, interval, 'daily', day.isoformat(), archive_base)
except ArchiveNotFound as exc:
raise RuntimeError(f"Missing Binance archive for required day {day.isoformat()}") from exc
all_bars.extend(bars)
manifest.append(meta)
recovered += 1
day += timedelta(days=1)
if recovered == 0:
raise RuntimeError(f"No Binance archive rows recovered for {stamp}")
cursor = next_month(cursor)
# The ending month may be incomplete and therefore may not have a monthly file yet.
day = max(end_month, source_start)
while day <= end:
try:
bars, meta = download_archive_file(symbol, interval, 'daily', day.isoformat(), archive_base)
except ArchiveNotFound as exc:
raise RuntimeError(
f"Binance daily archive is not available for {day.isoformat()}. "
"Daily archives are normally published after the UTC day closes; retry later or choose an earlier --end."
) from exc
all_bars.extend(bars)
manifest.append(meta)
day += timedelta(days=1)
dedup: dict[int, Bar] = {}
for b in all_bars:
if source_start <= b.day <= end:
dedup[b.open_time_ms] = b
bars = sorted(dedup.values(), key=lambda b: b.open_time_ms)
if not bars:
raise RuntimeError('Binance Public Data Archive returned no bars in the requested range')
return bars, archive_base, manifest
def fetch_klines(symbol: str, interval: str, start_ms: int, end_ms: int) -> tuple[list[Bar], str]:
last_error: Optional[Exception] = None
for base in BASE_URLS:
try:
rows: list[list] = []
cursor = start_ms
while cursor <= end_ms:
payload = api_get_json(base, "/api/v3/klines", {
"symbol": symbol,
"interval": interval,
"startTime": cursor,
"endTime": end_ms,
"limit": 1000,
"timeZone": "0",
})
if not isinstance(payload, list):
raise RuntimeError(f"Unexpected Binance response: {payload!r}")
if not payload:
break
rows.extend(payload)
next_cursor = int(payload[-1][0]) + 1
if next_cursor <= cursor:
raise RuntimeError("Binance pagination cursor did not advance")
cursor = next_cursor
if len(payload) < 1000:
break
time.sleep(0.12)
bars = [Bar(
open_time_ms=int(r[0]), open=float(r[1]), high=float(r[2]), low=float(r[3]), close=float(r[4]),
volume=float(r[5]), close_time_ms=int(r[6]), quote_asset_volume=float(r[7]),
number_of_trades=int(r[8]), taker_buy_base_volume=float(r[9]), taker_buy_quote_volume=float(r[10]),
) for r in rows]
bars.sort(key=lambda b: b.open_time_ms)
dedup: dict[int, Bar] = {b.open_time_ms: b for b in bars}
return list(dedup.values()), base
except Exception as exc:
last_error = exc
raise RuntimeError(f"All Binance endpoints failed. Last error: {last_error}")
def write_dataset_csv(path: Path, bars: Iterable[Bar]) -> str:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as f:
w = csv.writer(f, lineterminator="\n")
w.writerow(CSV_FIELDS)
for b in bars:
w.writerow([
b.open_time_ms, f"{b.open:.8f}", f"{b.high:.8f}", f"{b.low:.8f}", f"{b.close:.8f}",
f"{b.volume:.8f}", b.close_time_ms, f"{b.quote_asset_volume:.8f}", b.number_of_trades,
f"{b.taker_buy_base_volume:.8f}", f"{b.taker_buy_quote_volume:.8f}",
])
return sha256_bytes(path.read_bytes())
def read_dataset_csv(path: Path) -> list[Bar]:
out: list[Bar] = []
with path.open(newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
out.append(Bar(
open_time_ms=int(row["open_time_ms"]), open=float(row["open"]), high=float(row["high"]),
low=float(row["low"]), close=float(row["close"]), volume=float(row["volume"]),
close_time_ms=int(row["close_time_ms"]), quote_asset_volume=float(row["quote_asset_volume"]),
number_of_trades=int(row["number_of_trades"]), taker_buy_base_volume=float(row["taker_buy_base_volume"]),
taker_buy_quote_volume=float(row["taker_buy_quote_volume"]),
))
out.sort(key=lambda b: b.open_time_ms)
return out
def ema(values: list[float], period: int) -> list[Optional[float]]:
if period <= 0:
raise ValueError("EMA period must be positive")
out: list[Optional[float]] = [None] * len(values)
if len(values) < period:
return out
seed = sum(values[:period]) / period
out[period - 1] = seed
alpha = 2.0 / (period + 1.0)
prev = seed
for i in range(period, len(values)):
prev = alpha * values[i] + (1.0 - alpha) * prev
out[i] = prev
return out
def pct(v: Optional[float]) -> Optional[float]:
return None if v is None or not math.isfinite(v) else round(v, 8)
def safe_ratio(a: float, b: float) -> Optional[float]:
if b == 0:
return None
v = a / b
return v if math.isfinite(v) else None
def std_sample(values: list[float]) -> float:
return statistics.stdev(values) if len(values) >= 2 else 0.0
def backtest(bars: list[Bar], start: date, end: date, initial_capital: float, fee_pct: float, slippage_pct: float) -> dict:
if not bars:
raise RuntimeError("Dataset is empty")
by_day = {b.day: i for i, b in enumerate(bars)}
expected_days = [start + timedelta(days=i) for i in range((end - start).days + 1)]
missing = [d.isoformat() for d in expected_days if d not in by_day]
if missing:
raise RuntimeError(f"Dataset gap inside test period ({len(missing)} missing day(s)); first: {missing[:5]}")
closes = [b.close for b in bars]
ema50 = ema(closes, 50)
ema200 = ema(closes, 200)
test_indices = [by_day[d] for d in expected_days]
first_idx, last_idx = test_indices[0], test_indices[-1]
if ema200[first_idx] is None:
raise RuntimeError("Insufficient warm-up data for EMA200 at test start")
fee = fee_pct / 100.0
slip = slippage_pct / 100.0
cash = initial_capital
qty = 0.0
entry_cash = 0.0
entry_price = 0.0
entry_fee = 0.0
entry_time_ms = 0
in_position = False
trades: list[dict] = []
equity_series: list[list] = []
benchmark_series: list[list] = []
drawdown_series: list[list] = []
# Benchmark: buy at first test candle open, include entry friction and mark-to-market with exit friction.
b0 = bars[first_idx]
bench_entry_px = b0.open * (1.0 + slip)
bench_qty = initial_capital / (bench_entry_px * (1.0 + fee))
peak = initial_capital
exposure_days = 0
for idx in test_indices:
bar = bars[idx]
prev = idx - 1
# Execute signal generated on previous candle close at today's open.
if prev >= 1 and ema50[prev] is not None and ema200[prev] is not None and ema50[prev - 1] is not None and ema200[prev - 1] is not None:
cross_up = ema50[prev] > ema200[prev] and ema50[prev - 1] <= ema200[prev - 1]
cross_down = ema50[prev] < ema200[prev] and ema50[prev - 1] >= ema200[prev - 1]
if cross_up and not in_position:
entry_cash = cash
entry_price = bar.open * (1.0 + slip)
qty = cash / (entry_price * (1.0 + fee))
notional = qty * entry_price
entry_fee = notional * fee
cash = 0.0
entry_time_ms = bar.open_time_ms
in_position = True
elif cross_down and in_position:
exit_price = bar.open * (1.0 - slip)
gross = qty * exit_price
exit_fee = gross * fee
cash = gross - exit_fee
pnl = cash - entry_cash
trades.append({
"trade_no": len(trades) + 1,
"entry_time": iso_z_from_ms(entry_time_ms),
"entry_price": round(entry_price, 8),
"exit_time": iso_z_from_ms(bar.open_time_ms),
"exit_price": round(exit_price, 8),
"side": "LONG",
"position_size": round(qty, 12),
"fee": round(entry_fee + exit_fee, 8),
"pnl": round(pnl, 8),
"pnl_pct": round((pnl / entry_cash) * 100.0, 8),
"exit_reason": "EMA50 cross below EMA200",
})
qty = 0.0
in_position = False
if in_position:
exposure_days += 1
liquidation_px = bar.close * (1.0 - slip)
equity = qty * liquidation_px * (1.0 - fee)
else:
equity = cash
bench_liq = bench_qty * bar.close * (1.0 - slip) * (1.0 - fee)
peak = max(peak, equity)
dd = ((equity / peak) - 1.0) * 100.0 if peak > 0 else 0.0
stamp = bar.day.isoformat()
equity_series.append([stamp, round(equity, 8)])
benchmark_series.append([stamp, round(bench_liq, 8)])
drawdown_series.append([stamp, round(dd, 8)])
# Finite-window valuation: liquidate an open position at final close, clearly labeled.
if in_position:
bar = bars[last_idx]
exit_price = bar.close * (1.0 - slip)
gross = qty * exit_price
exit_fee = gross * fee
cash = gross - exit_fee
pnl = cash - entry_cash
trades.append({
"trade_no": len(trades) + 1,
"entry_time": iso_z_from_ms(entry_time_ms),
"entry_price": round(entry_price, 8),
"exit_time": datetime.combine(bar.day, datetime.max.time(), tzinfo=timezone.utc).strftime("%Y-%m-%dT23:59:59Z"),
"exit_price": round(exit_price, 8),
"side": "LONG",
"position_size": round(qty, 12),
"fee": round(entry_fee + exit_fee, 8),
"pnl": round(pnl, 8),
"pnl_pct": round((pnl / entry_cash) * 100.0, 8),
"exit_reason": "End-of-test liquidation for finite-window valuation",
})
equity_series[-1][1] = round(cash, 8)
peak_before_last = max(v[1] for v in equity_series)
drawdown_series[-1][1] = round(((cash / peak_before_last) - 1.0) * 100.0 if peak_before_last else 0.0, 8)
final_equity = float(equity_series[-1][1])
final_benchmark = float(benchmark_series[-1][1])
test_days = len(test_indices)
elapsed_days = max(1, (end - start).days)
years = elapsed_days / 365.2425
total_return = (final_equity / initial_capital - 1.0) * 100.0
annualized = ((final_equity / initial_capital) ** (1.0 / years) - 1.0) * 100.0 if final_equity > 0 else None
benchmark_return = (final_benchmark / initial_capital - 1.0) * 100.0
daily_returns: list[float] = []
eq = [float(x[1]) for x in equity_series]
for a, b in zip(eq, eq[1:]):
if a > 0:
daily_returns.append(b / a - 1.0)
mean_daily = statistics.fmean(daily_returns) if daily_returns else 0.0
stdev_daily = std_sample(daily_returns)
downside = [min(0.0, r) for r in daily_returns]
downside_dev = math.sqrt(statistics.fmean([r * r for r in downside])) if downside else 0.0
sharpe = safe_ratio(mean_daily * math.sqrt(365.0), stdev_daily)
sortino = safe_ratio(mean_daily * math.sqrt(365.0), downside_dev)
max_dd = min(float(x[1]) for x in drawdown_series)
calmar = safe_ratio(annualized if annualized is not None else 0.0, abs(max_dd)) if max_dd != 0 else None
volatility = stdev_daily * math.sqrt(365.0) * 100.0
wins = [t for t in trades if t["pnl"] > 0]
losses = [t for t in trades if t["pnl"] < 0]
gross_profit = sum(float(t["pnl"]) for t in wins)
gross_loss = abs(sum(float(t["pnl"]) for t in losses))
profit_factor = safe_ratio(gross_profit, gross_loss)
win_rate = (len(wins) / len(trades) * 100.0) if trades else None
loss_rate = (len(losses) / len(trades) * 100.0) if trades else None
expectancy = statistics.fmean([float(t["pnl_pct"]) for t in trades]) if trades else None
avg_win = statistics.fmean([float(t["pnl_pct"]) for t in wins]) if wins else None
avg_loss = statistics.fmean([float(t["pnl_pct"]) for t in losses]) if losses else None
largest_win = max((float(t["pnl_pct"]) for t in trades), default=None)
largest_loss = min((float(t["pnl_pct"]) for t in trades), default=None)
max_cw = max_cl = cw = cl = 0
for t in trades:
if t["pnl"] > 0:
cw += 1; cl = 0; max_cw = max(max_cw, cw)
elif t["pnl"] < 0:
cl += 1; cw = 0; max_cl = max(max_cl, cl)
else:
cw = cl = 0
dd_values = [float(x[1]) for x in drawdown_series]
avg_dd = statistics.fmean([x for x in dd_values if x < 0]) if any(x < 0 for x in dd_values) else 0.0
longest_dd = cur_dd = 0
for x in dd_values:
if x < 0:
cur_dd += 1; longest_dd = max(longest_dd, cur_dd)
else:
cur_dd = 0
metrics = {
"total_return": pct(total_return),
"annualized_return": pct(annualized),
"benchmark_return": pct(benchmark_return),
"number_of_trades": len(trades),
"win_rate": pct(win_rate),
"profit_factor": pct(profit_factor),
"max_drawdown": pct(max_dd),
"sharpe_ratio": pct(sharpe),
"sortino_ratio": pct(sortino),
"calmar_ratio": pct(calmar),
"expectancy": pct(expectancy),
}
advanced = {
"winning_trades": {"value": len(wins), "unit": "count"},
"losing_trades": {"value": len(losses), "unit": "count"},
"loss_rate": {"value": pct(loss_rate), "unit": "%"},
"average_win": {"value": pct(avg_win), "unit": "%"},
"average_loss": {"value": pct(avg_loss), "unit": "%"},
"average_drawdown": {"value": pct(avg_dd), "unit": "%"},
"volatility": {"value": pct(volatility), "unit": "% annualized"},
"exposure": {"value": pct(exposure_days / test_days * 100.0), "unit": "%"},
"largest_win": {"value": pct(largest_win), "unit": "%"},
"largest_loss": {"value": pct(largest_loss), "unit": "%"},
"consecutive_wins": {"value": max_cw, "unit": "count"},
"consecutive_losses": {"value": max_cl, "unit": "count"},
"max_drawdown_duration_days": {"value": longest_dd, "unit": "days"},
"final_equity": {"value": round(final_equity, 8), "unit": "USDT"},
"benchmark_final_equity": {"value": round(final_benchmark, 8), "unit": "USDT"},
}
return {
"metrics": metrics,
"advanced_metrics": advanced,
"series": {
"strategy_equity": equity_series,
"benchmark_equity": benchmark_series,
"drawdown": drawdown_series,
},
"trades": trades,
}
def build_result(args, bars: list[Bar], source_base: str, dataset_path: Path, dataset_sha: str, archive_manifest: Optional[list[dict]] = None) -> dict:
start = date.fromisoformat(args.start)
end = date.fromisoformat(args.end)
result = backtest(bars, start, end, args.initial_capital, args.fee_pct, args.slippage_pct)
archive_manifest = archive_manifest or []
archive_manifest_hash = sha256_bytes(canonical_json_bytes([
{"file": x.get("file"), "sha256": x.get("sha256"), "rows": x.get("rows")} for x in archive_manifest
])) if archive_manifest else None
using_archive = bool(archive_manifest)
provenance = {
"runner_version": RUNNER_VERSION,
"source": "Binance Public Data Archive / spot/klines" if using_archive else "Binance Spot REST /api/v3/klines",
"source_base": source_base,
"source_timezone": "UTC",
"symbol": args.symbol,
"interval": args.interval,
"dataset_file": dataset_path.name,
"dataset_sha256": dataset_sha,
"dataset_rows": len(bars),
"source_first_bar": bars[0].day.isoformat(),
"source_last_bar": bars[-1].day.isoformat(),
"execution_model": "signal on daily close; execute next daily open; long-only; fixed fee/slippage; end-of-test liquidation",
"generated_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"python": sys.version.split()[0],
"archive_files": len(archive_manifest),
"archive_manifest_sha256": archive_manifest_hash,
"archive_checksum_verified": bool(archive_manifest),
}
advanced = result["advanced_metrics"]
advanced.update({
"dataset_sha256": {"text": dataset_sha},
"runner_version": {"text": RUNNER_VERSION},
"dataset_rows": {"value": len(bars), "unit": "rows"},
"source_first_bar": {"text": provenance["source_first_bar"]},
"source_last_bar": {"text": provenance["source_last_bar"]},
"archive_files": {"value": len(archive_manifest), "unit": "files"},
"archive_manifest_sha256": {"text": archive_manifest_hash} if archive_manifest_hash else {"text": ""},
"archive_checksum_verified": {"text": "yes" if archive_manifest else "n/a"},
})
payload = {
"schema": RESULT_SCHEMA,
"mode": "apply_result",
"target": {
"backtest_slug": "ema-50-200-btc-1d-baseline",
"strategy_slug": "ema-50-200-btc",
"strategy_version": "1.0",
},
"config": {
"symbol": "BTC/USDT",
"market": "Crypto",
"exchange": "Binance Spot",
"timeframe": "1D",
"start_date": args.start,
"end_date": args.end,
"initial_capital": args.initial_capital,
"fee_pct": args.fee_pct,
"slippage_pct": args.slippage_pct,
"leverage": 1.0,
"benchmark": "BTC Buy & Hold",
"data_source": "Binance Public Data Archive / spot/klines" if using_archive else "Binance Spot /api/v3/klines",
"data_resolution": "1D UTC",
"dataset_version": f"binance-BTCUSDT-1d-{provenance['source_first_bar']}_{args.end}@{dataset_sha[:12]}",
"code_version": f"EMA-CROSS-BTC-V1.0+{RUNNER_VERSION}",
"parameters": {
"ema_fast": 50,
"ema_slow": 200,
"signal_confirmation": "daily_close",
"execution": "next_candle_open",
"direction": "long_only",
"pyramiding": False,
"end_of_test": "liquidate_at_last_close_for_finite_window_valuation",
},
},
"run_status": "completed",
"publish_status": "draft",
"is_demo": False,
"executed_at": provenance["generated_at_utc"],
"metrics": result["metrics"],
"advanced_metrics": advanced,
"series": result["series"],
"trades": result["trades"],
"provenance": provenance,
}
hash_view = dict(payload)
hash_view["provenance"] = dict(provenance)
result_sha = sha256_bytes(canonical_json_bytes(hash_view))
payload["provenance"]["result_payload_sha256"] = result_sha
payload["advanced_metrics"]["result_payload_sha256"] = {"text": result_sha}
return payload
def main() -> int:
p = argparse.ArgumentParser(description="Nexito EMA 50/200 BTC/USDT reproducible baseline runner")
p.add_argument("--symbol", default="BTCUSDT")
p.add_argument("--interval", default="1d")
p.add_argument("--source-start", default="2017-08-17", help="warm-up/source fetch start (UTC)")
p.add_argument("--source", choices=["archive", "api", "auto"], default="archive", help="market-data source; archive is the reproducible default")
p.add_argument("--archive-base", default=ARCHIVE_BASE, help="Binance Public Data Archive base URL")
p.add_argument("--start", default="2020-01-01")
p.add_argument("--end", default="2026-08-16")
p.add_argument("--initial-capital", type=float, default=10000.0)
p.add_argument("--fee-pct", type=float, default=0.10)
p.add_argument("--slippage-pct", type=float, default=0.05)
p.add_argument("--dataset", help="Use an existing frozen dataset CSV instead of downloading")
p.add_argument("--output-dir", default="quant-run-output")
args = p.parse_args()
start = date.fromisoformat(args.start)
end = date.fromisoformat(args.end)
source_start = date.fromisoformat(args.source_start)
if end < start:
raise SystemExit("end date must be >= start date")
if end >= datetime.now(timezone.utc).date():
raise SystemExit("end date must be a fully closed UTC day")
out_dir = Path(args.output_dir).resolve()
out_dir.mkdir(parents=True, exist_ok=True)
dataset_path = Path(args.dataset).resolve() if args.dataset else out_dir / "NEXITO-FIRST-LAB-RUN-dataset.csv"
source_base = "frozen-local-dataset"
archive_manifest: list[dict] = []
if args.dataset:
bars = read_dataset_csv(dataset_path)
dataset_sha = sha256_bytes(dataset_path.read_bytes())
else:
if args.source in ("archive", "auto"):
try:
bars, source_base, archive_manifest = fetch_klines_archive(args.symbol, args.interval, source_start, end, args.archive_base)
except Exception as archive_exc:
if args.source == "archive":
raise
print(f"WARN: Binance archive failed: {archive_exc}; trying REST API fallback", file=sys.stderr)
start_ms = int(datetime.combine(source_start, datetime.min.time(), tzinfo=timezone.utc).timestamp() * 1000)
end_ms = int(datetime.combine(end, datetime.max.time(), tzinfo=timezone.utc).timestamp() * 1000)
bars, source_base = fetch_klines(args.symbol, args.interval, start_ms, end_ms)
else:
start_ms = int(datetime.combine(source_start, datetime.min.time(), tzinfo=timezone.utc).timestamp() * 1000)
end_ms = int(datetime.combine(end, datetime.max.time(), tzinfo=timezone.utc).timestamp() * 1000)
bars, source_base = fetch_klines(args.symbol, args.interval, start_ms, end_ms)
if not bars:
raise SystemExit("Binance returned no bars")
dataset_sha = write_dataset_csv(dataset_path, bars)
payload = build_result(args, bars, source_base, dataset_path, dataset_sha, archive_manifest)
result_path = out_dir / "NEXITO-FIRST-LAB-RUN-result.json"
result_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
manifest = {
"runner_version": RUNNER_VERSION,
"result_schema": RESULT_SCHEMA,
"dataset_file": dataset_path.name,
"dataset_sha256": dataset_sha,
"result_file": result_path.name,
"result_file_sha256": sha256_bytes(result_path.read_bytes()),
"result_payload_sha256": payload["provenance"]["result_payload_sha256"],
"test_period": [args.start, args.end],
"metrics": payload["metrics"],
"visual_rows": {k: len(v) for k, v in payload["series"].items()},
"trades": len(payload["trades"]),
}
manifest_path = out_dir / "NEXITO-FIRST-LAB-RUN-manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
sums = [
f"{dataset_sha} {dataset_path.name}",
f"{sha256_bytes(result_path.read_bytes())} {result_path.name}",
f"{sha256_bytes(manifest_path.read_bytes())} {manifest_path.name}",
]
(out_dir / "SHA256SUMS.txt").write_text("\n".join(sums) + "\n", encoding="utf-8")
print("NEXITO FIRST LAB RUN COMPLETE")
print(f"Dataset : {dataset_path}")
print(f"SHA256 : {dataset_sha}")
print(f"Result : {result_path}")
print(f"Trades : {len(payload['trades'])}")
for key, value in payload["metrics"].items():
print(f"{key:20s}: {value}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
raise SystemExit(130)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)