- orb_stocks_in_play: 5-min bounds -70%/yr (conservative) vs +71%/yr (optimistic); the optimistic bound breaks even at 3.6 bp slippage a side. - overnight_only: the momentum sleeve's names earn their return overnight (Sharpe 1.57) and nothing intraday; held close->open only, net Sharpe 1.29-1.42 at 1 bp a side, below the 1.5 gate. - overnight_paper.py + systemd unit: MOC buy / MOO sell of the top 10 on the Alpaca paper account from 2026-10-12, with a fills-vs-prints report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
463 lines
20 KiB
Python
463 lines
20 KiB
Python
"""
|
|
Forward test of the overnight-only momentum book on Alpaca's PAPER account
|
|
(research: research/lab/overnight_only.py). Each session it buys the top 10 of
|
|
overnight_recovery's score with market-on-close orders and sells every
|
|
position with market-on-open orders at the next session, so the book is flat
|
|
during the day. The test measures what the backtest cannot: fills against the
|
|
official close/open prints, and the forward record.
|
|
|
|
uv run python overnight_paper.py signal # today's list, no orders
|
|
uv run python overnight_paper.py buy [--dry-run] # MOC orders; between close-25 and close-11 min
|
|
uv run python overnight_paper.py sell [--dry-run] # MOO sells; between 19:00 ET and open-2 min
|
|
uv run python overnight_paper.py report # fills vs official prints, nightly returns
|
|
uv run python overnight_paper.py daemon # runs sell / buy / report on Alpaca's calendar
|
|
|
|
Rules (as in the study's sleeve_score candidate):
|
|
score strategies.robust_momentum.signal_overnight_recovery (12-1 month
|
|
overnight momentum + distance from the 126-day low, percentile
|
|
blend) on Alpaca SIP daily bars (split/dividend adjusted) through
|
|
the previous session, current S&P 500 members
|
|
book top 10, equal weight, whole shares (Alpaca accepts only whole-share
|
|
MOC/MOO orders), 1x the account equity, no leverage
|
|
orders buy: market, time_in_force "cls", sent at close - 20 min (Alpaca
|
|
rejects MOC orders after 15:50 ET); sell: market, "opg", sent at
|
|
open - 20 min. A position left over from a failed sell is resized
|
|
to the new target at the close.
|
|
Keys come from ALPACA_API_ENDPOINT / ALPACA_API_KEY / ALPACA_API_SECRET in the
|
|
environment or .env; they are never printed. The script refuses any endpoint
|
|
that is not paper-api.alpaca.markets.
|
|
|
|
Boundary: Alpaca's paper engine simulates fills from live quotes; it does not
|
|
reproduce the exchange auctions or market impact. The report therefore scores
|
|
both the paper fills and the official open/close prints of the same names.
|
|
|
|
State and logs: data/overnight_paper/ (orders.jsonl, signals/<date>.json,
|
|
state.json, fills.csv, nights.csv).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pandas as pd
|
|
import requests
|
|
|
|
from strategies.robust_momentum import signal_overnight_recovery
|
|
|
|
OUT = os.path.join("data", "overnight_paper")
|
|
DATA_URL = "https://data.alpaca.markets/v2"
|
|
PAPER_HOST = "paper-api.alpaca.markets"
|
|
NY = ZoneInfo("America/New_York")
|
|
TOP_N = 10
|
|
HISTORY_DAYS = 450 # calendar days of daily bars (score needs ~280 sessions)
|
|
BUY_BEFORE_CLOSE = timedelta(minutes=20)
|
|
BUY_CUTOFF = timedelta(minutes=11) # Alpaca: MOC orders close at 15:50 ET
|
|
SELL_BEFORE_OPEN = timedelta(minutes=20)
|
|
SELL_CUTOFF = timedelta(minutes=2) # Alpaca: MOO orders close at 09:28 ET
|
|
REPORT_AFTER_OPEN = timedelta(minutes=30)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Alpaca client (paper only; keys never printed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _env() -> dict[str, str]:
|
|
env = dict(os.environ)
|
|
if os.path.exists(".env"):
|
|
for line in open(".env"):
|
|
if "=" in line and not line.lstrip().startswith("#"):
|
|
k, v = line.split("=", 1)
|
|
env.setdefault(k.strip(), v.strip().strip("'\""))
|
|
return env
|
|
|
|
|
|
class Alpaca:
|
|
def __init__(self) -> None:
|
|
env = _env()
|
|
base = env["ALPACA_API_ENDPOINT"].rstrip("/")
|
|
if PAPER_HOST not in base:
|
|
raise SystemExit("ALPACA_API_ENDPOINT is not the paper API; refusing to trade")
|
|
self.base = base if base.endswith("/v2") else base + "/v2"
|
|
self.s = requests.Session()
|
|
self.s.headers.update({"APCA-API-KEY-ID": env["ALPACA_API_KEY"],
|
|
"APCA-API-SECRET-KEY": env["ALPACA_API_SECRET"]})
|
|
|
|
def _req(self, method: str, url: str, **kw) -> requests.Response:
|
|
for attempt in range(6):
|
|
try:
|
|
r = self.s.request(method, url, timeout=30, **kw)
|
|
except requests.RequestException:
|
|
time.sleep(2 ** attempt)
|
|
continue
|
|
if r.status_code == 429 or r.status_code >= 500:
|
|
time.sleep(2 ** attempt)
|
|
continue
|
|
return r
|
|
raise RuntimeError(f"{method} {url.split('/v2')[-1]}: retries exhausted")
|
|
|
|
def get(self, path: str, data: bool = False, **params):
|
|
r = self._req("GET", (DATA_URL if data else self.base) + path, params=params)
|
|
if r.status_code != 200:
|
|
raise RuntimeError(f"GET {path}: HTTP {r.status_code} {r.text[:200]}")
|
|
return r.json()
|
|
|
|
def order(self, payload: dict) -> dict:
|
|
r = self._req("POST", self.base + "/orders", json=payload)
|
|
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {"text": r.text}
|
|
return {"http": r.status_code, **body}
|
|
|
|
def cancel(self, order_id: str) -> int:
|
|
return self._req("DELETE", f"{self.base}/orders/{order_id}").status_code
|
|
|
|
|
|
def alpaca_symbol(yahoo: str) -> str:
|
|
return yahoo.replace("-", ".")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# calendar
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def sessions(cl: Alpaca, start: date, end: date) -> pd.DataFrame:
|
|
rows = cl.get("/calendar", start=start.isoformat(), end=end.isoformat())
|
|
out = []
|
|
for c in rows:
|
|
d = date.fromisoformat(c["date"])
|
|
o = datetime.combine(d, datetime.strptime(c["open"], "%H:%M").time(), NY)
|
|
k = datetime.combine(d, datetime.strptime(c["close"], "%H:%M").time(), NY)
|
|
out.append({"date": d, "open": o, "close": k})
|
|
return pd.DataFrame(out).set_index("date")
|
|
|
|
|
|
def now_utc() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# signal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def sp500() -> list[str]:
|
|
try:
|
|
from universe import get_sp500
|
|
return get_sp500()
|
|
except Exception as exc: # noqa: BLE001 - Wikipedia unreachable: last cached list
|
|
with open(os.path.join("data", "universe_sp500.json")) as fh:
|
|
cached = json.load(fh)
|
|
print(f"S&P 500 list from cache dated {cached.get('date')} ({type(exc).__name__})", flush=True)
|
|
return cached["tickers"]
|
|
|
|
|
|
def _data_end(end: date) -> str:
|
|
"""The free plan refuses SIP data from the last 15 minutes; a bare date counts as that whole day."""
|
|
stop = min(datetime.combine(end, datetime.strptime("23:59", "%H:%M").time(), NY), # daily bars are stamped 00:00 ET
|
|
now_utc() - timedelta(minutes=20))
|
|
return stop.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def daily_bars(cl: Alpaca, symbols: list[str], start: date, end: date) -> dict[str, pd.DataFrame]:
|
|
"""Adjusted SIP daily open/close, one column per Yahoo-spelled symbol."""
|
|
rows = []
|
|
amap = {alpaca_symbol(s): s for s in symbols}
|
|
names = list(amap)
|
|
for i in range(0, len(names), 100):
|
|
params = {"symbols": ",".join(names[i:i + 100]), "timeframe": "1Day", "start": start.isoformat(),
|
|
"end": _data_end(end), "adjustment": "all", "feed": "sip", "limit": 10000}
|
|
while True:
|
|
j = cl.get("/stocks/bars", data=True, **params)
|
|
for sym, bars in (j.get("bars") or {}).items():
|
|
for b in bars:
|
|
rows.append((amap[sym], b["t"][:10], b["o"], b["c"]))
|
|
tok = j.get("next_page_token")
|
|
if not tok:
|
|
break
|
|
params["page_token"] = tok
|
|
df = pd.DataFrame(rows, columns=["sym", "date", "open", "close"])
|
|
df["date"] = pd.to_datetime(df["date"])
|
|
return {f: df.pivot(index="date", columns="sym", values=f).sort_index() for f in ("open", "close")}
|
|
|
|
|
|
def compute_signal(cl: Alpaca, session: date) -> dict:
|
|
"""Top 10 for the close of ``session`` from bars through the previous session."""
|
|
cal = sessions(cl, session - timedelta(days=10), session)
|
|
prev = [d for d in cal.index if d < session]
|
|
if not prev:
|
|
raise RuntimeError(f"no session before {session}")
|
|
last = prev[-1]
|
|
syms = sp500()
|
|
bars = daily_bars(cl, syms, session - timedelta(days=HISTORY_DAYS), last)
|
|
if bars["close"].index[-1].date() != last:
|
|
raise RuntimeError(f"daily bars end {bars['close'].index[-1].date()}, expected {last}")
|
|
score = signal_overnight_recovery({"open": bars["open"], "close": bars["close"]})
|
|
row = score.iloc[-1].dropna().sort_values(ascending=False, kind="mergesort")
|
|
top = list(row.index[:TOP_N])
|
|
return {"session": session.isoformat(), "bars_through": last.isoformat(), "universe": len(syms),
|
|
"scored": int(row.size), "top": top, "scores": {s: round(float(row[s]), 4) for s in top},
|
|
"prev_close": {s: float(bars["close"][s].iloc[-1]) for s in top}}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# state / logs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _state() -> dict:
|
|
path = os.path.join(OUT, "state.json")
|
|
return json.load(open(path)) if os.path.exists(path) else {}
|
|
|
|
|
|
def _save_state(st: dict) -> None:
|
|
os.makedirs(OUT, exist_ok=True)
|
|
tmp = os.path.join(OUT, ".state.json.tmp")
|
|
json.dump(st, open(tmp, "w"), indent=1)
|
|
os.replace(tmp, os.path.join(OUT, "state.json"))
|
|
|
|
|
|
def _log_order(kind: str, payload: dict, resp: dict) -> None:
|
|
os.makedirs(OUT, exist_ok=True)
|
|
keep = {k: resp.get(k) for k in ("http", "id", "client_order_id", "status", "symbol", "qty", "side",
|
|
"time_in_force", "submitted_at", "message", "text")}
|
|
with open(os.path.join(OUT, "orders.jsonl"), "a") as fh:
|
|
fh.write(json.dumps({"logged_at": now_utc().isoformat(), "kind": kind, "payload": payload,
|
|
"response": keep}) + "\n")
|
|
|
|
|
|
def _submit(cl: Alpaca, kind: str, payload: dict, dry: bool) -> None:
|
|
tag = f"{payload['side']:4s} {payload['qty']:>6s} {payload['symbol']:6s} {payload['time_in_force']}"
|
|
if dry:
|
|
print(f" [dry-run] {tag}", flush=True)
|
|
return
|
|
resp = cl.order(payload)
|
|
_log_order(kind, payload, resp)
|
|
ok = resp["http"] in (200, 201)
|
|
dup = resp["http"] == 422 and "client_order_id" in json.dumps(resp).lower()
|
|
print(f" {tag} -> {'ok ' + str(resp.get('status')) if ok else 'already sent' if dup else 'HTTP ' + str(resp['http']) + ' ' + str(resp.get('message', resp.get('text', '')))[:120]}",
|
|
flush=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def positions(cl: Alpaca) -> dict[str, int]:
|
|
return {p["symbol"]: int(float(p["qty"])) for p in cl.get("/positions")}
|
|
|
|
|
|
def open_orders(cl: Alpaca) -> list[dict]:
|
|
return cl.get("/orders", status="open", limit=500)
|
|
|
|
|
|
def latest_prices(cl: Alpaca, symbols: list[str]) -> dict[str, float]:
|
|
"""Last trade on the free real-time feed (IEX); sizing only."""
|
|
j = cl.get("/stocks/trades/latest", data=True, symbols=",".join(alpaca_symbol(s) for s in symbols), feed="iex")
|
|
return {s: float(j["trades"][alpaca_symbol(s)]["p"]) for s in symbols if alpaca_symbol(s) in j.get("trades", {})}
|
|
|
|
|
|
def buy(cl: Alpaca, dry: bool = False, force: bool = False) -> None:
|
|
today = datetime.now(NY).date()
|
|
cal = sessions(cl, today, today)
|
|
if today not in cal.index:
|
|
print(f"{today}: no session", flush=True)
|
|
return
|
|
close = cal.loc[today, "close"]
|
|
if not force and not (close - BUY_BEFORE_CLOSE - timedelta(minutes=5) <= now_utc() <= close - BUY_CUTOFF):
|
|
print(f"{today}: outside the MOC window (close {close:%H:%M} ET); use --force for a dry check", flush=True)
|
|
if not dry:
|
|
return
|
|
st = _state()
|
|
if st.get("bought") == today.isoformat() and not dry:
|
|
print(f"{today}: MOC orders already sent", flush=True)
|
|
return
|
|
sig = compute_signal(cl, today)
|
|
os.makedirs(os.path.join(OUT, "signals"), exist_ok=True)
|
|
json.dump(sig, open(os.path.join(OUT, "signals", f"{today}.json"), "w"), indent=1)
|
|
acct = cl.get("/account")
|
|
equity = float(acct["equity"])
|
|
px = latest_prices(cl, sig["top"])
|
|
held = positions(cl)
|
|
pending = {o["symbol"] for o in open_orders(cl) if o["time_in_force"] == "cls"}
|
|
target = {}
|
|
for s in sig["top"]:
|
|
p = px.get(s) or sig["prev_close"][s]
|
|
target[alpaca_symbol(s)] = math.floor(equity / TOP_N / p)
|
|
print(f"{today}: equity {equity:,.0f}, bars through {sig['bars_through']}, top {sig['top']}", flush=True)
|
|
for sym in sorted(set(target) | set(held)):
|
|
if sym in pending:
|
|
print(f" {sym}: MOC order already open", flush=True)
|
|
continue
|
|
delta = target.get(sym, 0) - held.get(sym, 0)
|
|
if delta == 0:
|
|
continue
|
|
payload = {"symbol": sym, "qty": str(abs(delta)), "side": "buy" if delta > 0 else "sell",
|
|
"type": "market", "time_in_force": "cls", "client_order_id": f"onb-{today}-{sym}"}
|
|
_submit(cl, "moc", payload, dry)
|
|
if not dry:
|
|
st["bought"] = today.isoformat()
|
|
_save_state(st)
|
|
|
|
|
|
def sell(cl: Alpaca, dry: bool = False) -> None:
|
|
now = now_utc()
|
|
cal = sessions(cl, (now - timedelta(days=1)).date(), (now + timedelta(days=10)).date())
|
|
nxt = cal[cal["open"] > now]
|
|
if nxt.empty:
|
|
print("no upcoming session", flush=True)
|
|
return
|
|
session, opn = nxt.index[0], nxt["open"].iloc[0]
|
|
if not dry and now > opn - SELL_CUTOFF:
|
|
print(f"{session}: too late for MOO orders", flush=True)
|
|
return
|
|
st = _state()
|
|
if st.get("sold") == session.isoformat() and not dry:
|
|
print(f"{session}: MOO orders already sent", flush=True)
|
|
return
|
|
held = {s: q for s, q in positions(cl).items() if q > 0}
|
|
pending = {o["symbol"] for o in open_orders(cl) if o["time_in_force"] == "opg" and o["side"] == "sell"}
|
|
print(f"{session}: MOO sells for {len(held)} positions", flush=True)
|
|
for sym, q in sorted(held.items()):
|
|
if sym in pending:
|
|
print(f" {sym}: MOO order already open", flush=True)
|
|
continue
|
|
payload = {"symbol": sym, "qty": str(q), "side": "sell", "type": "market", "time_in_force": "opg",
|
|
"client_order_id": f"ons-{session}-{sym}"}
|
|
_submit(cl, "moo", payload, dry)
|
|
if not dry:
|
|
st["sold"] = session.isoformat()
|
|
_save_state(st)
|
|
|
|
|
|
def report(cl: Alpaca) -> None:
|
|
"""Fills against the official prints (Alpaca SIP daily open/close, unadjusted) and nightly returns."""
|
|
orders, after = [], (now_utc() - timedelta(days=400)).isoformat()
|
|
while True:
|
|
page = cl.get("/orders", status="closed", limit=500, direction="asc", after=after)
|
|
orders += page
|
|
if len(page) < 500:
|
|
break
|
|
after = page[-1]["submitted_at"]
|
|
rows = []
|
|
for o in orders:
|
|
cid = o.get("client_order_id", "")
|
|
if not cid.startswith(("onb-", "ons-")) or o.get("filled_avg_price") is None:
|
|
continue
|
|
rows.append({"session": cid[4:14], "symbol": o["symbol"], "side": o["side"], "tif": o["time_in_force"],
|
|
"qty": float(o["filled_qty"]), "fill": float(o["filled_avg_price"]),
|
|
"filled_at": o["filled_at"]})
|
|
if not rows:
|
|
print("no fills yet", flush=True)
|
|
return
|
|
F = pd.DataFrame(rows)
|
|
d0 = date.fromisoformat(F["session"].min())
|
|
d1 = date.fromisoformat(F["session"].max())
|
|
rows = []
|
|
syms = sorted(F["symbol"].unique())
|
|
for i in range(0, len(syms), 100):
|
|
params = {"symbols": ",".join(syms[i:i + 100]), "timeframe": "1Day", "start": d0.isoformat(),
|
|
"end": _data_end(d1), "adjustment": "raw", "feed": "sip", "limit": 10000}
|
|
while True:
|
|
j = cl.get("/stocks/bars", data=True, **params)
|
|
for sym, bars in (j.get("bars") or {}).items():
|
|
rows += [(sym, b["t"][:10], b["o"], b["c"]) for b in bars]
|
|
if not j.get("next_page_token"):
|
|
break
|
|
params["page_token"] = j["next_page_token"]
|
|
P = pd.DataFrame(rows, columns=["symbol", "session", "open", "close"])
|
|
F = F.merge(P, on=["symbol", "session"], how="left")
|
|
F["ref"] = F["close"].where(F["tif"] == "cls", F["open"])
|
|
# cost in bp: positive = worse than the official print
|
|
sign = F["side"].map({"buy": 1, "sell": -1})
|
|
F["slip_bp"] = sign * (F["fill"] / F["ref"] - 1) * 1e4
|
|
F.to_csv(os.path.join(OUT, "fills.csv"), index=False)
|
|
b = F[F["tif"] == "cls"]
|
|
s = F[F["tif"] == "opg"]
|
|
print(f"fills {len(F)}: MOC slip {b['slip_bp'].mean():.2f} bp (median {b['slip_bp'].median():.2f}), "
|
|
f"MOO slip {s['slip_bp'].mean():.2f} bp (median {s['slip_bp'].median():.2f})", flush=True)
|
|
# nights: buy session d (close) -> sell next session (open)
|
|
nights = []
|
|
for d, g in b.groupby("session"):
|
|
later = s[s["session"] > d]
|
|
if later.empty:
|
|
continue
|
|
nd = later["session"].min()
|
|
sg = later[later["session"] == nd].set_index("symbol")
|
|
bg = g.set_index("symbol")
|
|
both = bg.index.intersection(sg.index)
|
|
if both.empty:
|
|
continue
|
|
cost = (bg.loc[both, "qty"] * bg.loc[both, "fill"]).sum()
|
|
pnl = (sg.loc[both, "qty"] * sg.loc[both, "fill"] - bg.loc[both, "qty"] * bg.loc[both, "fill"]).sum()
|
|
model = (sg.loc[both, "open"] / bg.loc[both, "close"] - 1).mean()
|
|
nights.append({"buy_session": d, "sell_session": nd, "names": len(both),
|
|
"paper_ret": pnl / cost, "print_ret": model})
|
|
if nights:
|
|
N = pd.DataFrame(nights)
|
|
N.to_csv(os.path.join(OUT, "nights.csv"), index=False)
|
|
print(N.tail(10).to_string(index=False, float_format=lambda x: f"{x:.4f}"), flush=True)
|
|
print(f"nights {len(N)}: paper mean {N['paper_ret'].mean()*1e4:.1f} bp/night, "
|
|
f"prints mean {N['print_ret'].mean()*1e4:.1f} bp/night", flush=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# daemon
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def daemon() -> None:
|
|
cl = Alpaca()
|
|
print(f"{now_utc():%Y-%m-%d %H:%M} UTC daemon started (paper account)", flush=True)
|
|
done: set[tuple[str, str]] = set()
|
|
while True:
|
|
try:
|
|
now = now_utc()
|
|
cal = sessions(cl, (now - timedelta(days=1)).date(), (now + timedelta(days=10)).date())
|
|
for d, row in cal.iterrows():
|
|
key = d.isoformat()
|
|
sell_at, buy_at = row["open"] - SELL_BEFORE_OPEN, row["close"] - BUY_BEFORE_CLOSE
|
|
if ("sell", key) not in done and sell_at <= now < row["open"] - SELL_CUTOFF:
|
|
sell(cl)
|
|
done.add(("sell", key))
|
|
if ("buy", key) not in done and buy_at <= now < row["close"] - BUY_CUTOFF:
|
|
buy(cl)
|
|
done.add(("buy", key))
|
|
if ("report", key) not in done and row["open"] + REPORT_AFTER_OPEN <= now < row["close"]:
|
|
report(cl)
|
|
done.add(("report", key))
|
|
except Exception as exc: # noqa: BLE001 - keep the loop alive; the next tick retries
|
|
print(f"{now_utc():%Y-%m-%d %H:%M} UTC error: {type(exc).__name__}: {str(exc)[:300]}", flush=True)
|
|
time.sleep(60)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("what", choices=["signal", "buy", "sell", "report", "daemon"])
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
ap.add_argument("--force", action="store_true", help="buy: ignore the time window (still honours --dry-run)")
|
|
a = ap.parse_args()
|
|
if a.what == "daemon":
|
|
daemon()
|
|
return
|
|
cl = Alpaca()
|
|
if a.what == "signal":
|
|
today = datetime.now(NY).date()
|
|
cal = sessions(cl, today, today + timedelta(days=10))
|
|
nxt = cal[cal["close"] > now_utc()].index[0]
|
|
sig = compute_signal(cl, nxt)
|
|
print(json.dumps(sig, indent=1))
|
|
elif a.what == "buy":
|
|
if a.force and not a.dry_run:
|
|
sys.exit("--force is only for --dry-run checks")
|
|
buy(cl, dry=a.dry_run, force=a.force)
|
|
elif a.what == "sell":
|
|
sell(cl, dry=a.dry_run)
|
|
else:
|
|
report(cl)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|