opentradex 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/CLAUDE.md +98 -0
- package/README.md +246 -0
- package/SOUL.md +79 -0
- package/SPEC.md +317 -0
- package/SUBMISSION.md +30 -0
- package/architecture.excalidraw +170 -0
- package/architecture.png +0 -0
- package/bin/opentradex.mjs +4 -0
- package/data/.gitkeep +0 -0
- package/data/strategy_notes.md +158 -0
- package/gossip/__init__.py +0 -0
- package/gossip/dashboard.py +150 -0
- package/gossip/db.py +358 -0
- package/gossip/kalshi.py +492 -0
- package/gossip/news.py +235 -0
- package/gossip/trader.py +646 -0
- package/main.py +287 -0
- package/package.json +47 -0
- package/requirements.txt +7 -0
- package/src/cli.mjs +124 -0
- package/src/index.mjs +420 -0
- package/web/AGENTS.md +5 -0
- package/web/CLAUDE.md +1 -0
- package/web/README.md +36 -0
- package/web/components.json +25 -0
- package/web/eslint.config.mjs +18 -0
- package/web/next.config.ts +7 -0
- package/web/package-lock.json +11626 -0
- package/web/package.json +37 -0
- package/web/postcss.config.mjs +7 -0
- package/web/public/file.svg +1 -0
- package/web/public/globe.svg +1 -0
- package/web/public/next.svg +1 -0
- package/web/public/vercel.svg +1 -0
- package/web/public/window.svg +1 -0
- package/web/src/app/api/agent/route.ts +77 -0
- package/web/src/app/api/agent/stream/route.ts +87 -0
- package/web/src/app/api/markets/route.ts +15 -0
- package/web/src/app/api/news/live/route.ts +77 -0
- package/web/src/app/api/news/reddit/route.ts +118 -0
- package/web/src/app/api/news/route.ts +10 -0
- package/web/src/app/api/news/tiktok/route.ts +115 -0
- package/web/src/app/api/news/truthsocial/route.ts +116 -0
- package/web/src/app/api/news/twitter/route.ts +186 -0
- package/web/src/app/api/portfolio/route.ts +50 -0
- package/web/src/app/api/prices/route.ts +18 -0
- package/web/src/app/api/trades/route.ts +10 -0
- package/web/src/app/favicon.ico +0 -0
- package/web/src/app/globals.css +170 -0
- package/web/src/app/layout.tsx +36 -0
- package/web/src/app/page.tsx +366 -0
- package/web/src/components/AgentLog.tsx +71 -0
- package/web/src/components/LiveStream.tsx +394 -0
- package/web/src/components/MarketScanner.tsx +111 -0
- package/web/src/components/NewsFeed.tsx +561 -0
- package/web/src/components/PortfolioStrip.tsx +139 -0
- package/web/src/components/PositionsPanel.tsx +219 -0
- package/web/src/components/TopBar.tsx +127 -0
- package/web/src/components/ui/badge.tsx +52 -0
- package/web/src/components/ui/button.tsx +60 -0
- package/web/src/components/ui/card.tsx +103 -0
- package/web/src/components/ui/scroll-area.tsx +55 -0
- package/web/src/components/ui/separator.tsx +25 -0
- package/web/src/components/ui/tabs.tsx +82 -0
- package/web/src/components/ui/tooltip.tsx +66 -0
- package/web/src/lib/db.ts +81 -0
- package/web/src/lib/types.ts +130 -0
- package/web/src/lib/utils.ts +6 -0
- package/web/tsconfig.json +34 -0
package/gossip/trader.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trading engine — paper + live execution, Kelly sizing, portfolio management.
|
|
3
|
+
|
|
4
|
+
CLI tool invoked by Claude Code agent:
|
|
5
|
+
python3 gossip/trader.py portfolio
|
|
6
|
+
python3 gossip/trader.py trade TICKER --side yes --contracts 3 --estimate 0.72 --confidence high --reasoning "..."
|
|
7
|
+
python3 gossip/trader.py exit TICKER --reasoning "..."
|
|
8
|
+
python3 gossip/trader.py settle TICKER --outcome yes
|
|
9
|
+
python3 gossip/trader.py history
|
|
10
|
+
python3 gossip/trader.py size TICKER --estimate 0.72 (dry-run: shows recommended size)
|
|
11
|
+
|
|
12
|
+
All output is JSON to stdout.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass, field, asdict
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from dotenv import load_dotenv
|
|
28
|
+
|
|
29
|
+
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
|
|
30
|
+
|
|
31
|
+
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
|
32
|
+
TRADES_FILE = DATA_DIR / "trades.json"
|
|
33
|
+
PORTFOLIO_FILE = DATA_DIR / "portfolio.json"
|
|
34
|
+
|
|
35
|
+
def log(msg: str) -> None:
|
|
36
|
+
print(msg, file=sys.stderr)
|
|
37
|
+
|
|
38
|
+
def get_db():
|
|
39
|
+
from gossip.db import GossipDB
|
|
40
|
+
return GossipDB()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- State management ---
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Trade:
|
|
47
|
+
timestamp: str
|
|
48
|
+
ticker: str
|
|
49
|
+
title: str
|
|
50
|
+
category: str
|
|
51
|
+
side: str # "yes" or "no"
|
|
52
|
+
action: str # "buy" or "sell"
|
|
53
|
+
contracts: int
|
|
54
|
+
entry_price: float
|
|
55
|
+
cost: float
|
|
56
|
+
fee: float
|
|
57
|
+
estimated_prob: float
|
|
58
|
+
edge: float
|
|
59
|
+
confidence: str
|
|
60
|
+
reasoning: str
|
|
61
|
+
news_trigger: str = ""
|
|
62
|
+
sources: list[str] = field(default_factory=list)
|
|
63
|
+
settled: bool = False
|
|
64
|
+
outcome: str = "" # "win", "loss", ""
|
|
65
|
+
pnl: float = 0.0
|
|
66
|
+
exit_reasoning: str = ""
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Portfolio:
|
|
70
|
+
bankroll: float = 30.0
|
|
71
|
+
total_pnl: float = 0.0
|
|
72
|
+
total_trades: int = 0
|
|
73
|
+
wins: int = 0
|
|
74
|
+
losses: int = 0
|
|
75
|
+
trades: list[Trade] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def open_positions(self) -> list[Trade]:
|
|
79
|
+
return [t for t in self.trades if not t.settled and t.action == "buy"]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def win_rate(self) -> float:
|
|
83
|
+
if self.total_trades == 0:
|
|
84
|
+
return 0.0
|
|
85
|
+
return self.wins / self.total_trades
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def deployed_capital(self) -> float:
|
|
89
|
+
return sum(t.cost + t.fee for t in self.open_positions)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_portfolio() -> Portfolio:
|
|
93
|
+
if not TRADES_FILE.exists():
|
|
94
|
+
return Portfolio(bankroll=float(os.getenv("BANKROLL", "30.0")))
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
data = json.loads(TRADES_FILE.read_text())
|
|
98
|
+
p = Portfolio()
|
|
99
|
+
p.bankroll = data.get("bankroll", float(os.getenv("BANKROLL", "30.0")))
|
|
100
|
+
p.total_pnl = data.get("total_pnl", 0.0)
|
|
101
|
+
p.total_trades = data.get("total_trades", 0)
|
|
102
|
+
p.wins = data.get("wins", 0)
|
|
103
|
+
p.losses = data.get("losses", 0)
|
|
104
|
+
for t in data.get("trades", []):
|
|
105
|
+
p.trades.append(Trade(**t))
|
|
106
|
+
return p
|
|
107
|
+
except Exception as e:
|
|
108
|
+
log(f"Error loading portfolio: {e}")
|
|
109
|
+
return Portfolio(bankroll=float(os.getenv("BANKROLL", "30.0")))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def save_portfolio(p: Portfolio) -> None:
|
|
113
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
data = {
|
|
115
|
+
"bankroll": round(p.bankroll, 2),
|
|
116
|
+
"total_pnl": round(p.total_pnl, 2),
|
|
117
|
+
"total_trades": p.total_trades,
|
|
118
|
+
"wins": p.wins,
|
|
119
|
+
"losses": p.losses,
|
|
120
|
+
"trades": [asdict(t) for t in p.trades],
|
|
121
|
+
}
|
|
122
|
+
TRADES_FILE.write_text(json.dumps(data, indent=2))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- Sizing ---
|
|
126
|
+
|
|
127
|
+
def kalshi_fee(contracts: int, price: float) -> float:
|
|
128
|
+
return math.ceil(0.07 * contracts * price * (1 - price)) / 100
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def kelly_size(
|
|
132
|
+
estimated_prob: float,
|
|
133
|
+
market_price: float,
|
|
134
|
+
bankroll: float,
|
|
135
|
+
side: str = "yes",
|
|
136
|
+
kelly_fraction: float = 0.5,
|
|
137
|
+
max_position_pct: float = 0.30,
|
|
138
|
+
) -> dict:
|
|
139
|
+
"""Half-Kelly position sizing."""
|
|
140
|
+
if side == "yes":
|
|
141
|
+
p = estimated_prob
|
|
142
|
+
price = market_price
|
|
143
|
+
else:
|
|
144
|
+
p = 1 - estimated_prob
|
|
145
|
+
price = 1 - market_price
|
|
146
|
+
|
|
147
|
+
if price <= 0 or price >= 1 or p <= price:
|
|
148
|
+
return {"contracts": 0, "edge": 0, "reason": "no edge"}
|
|
149
|
+
|
|
150
|
+
edge = p - price
|
|
151
|
+
# Kelly: f* = (p * b - q) / b where b = (1-price)/price, q = 1-p
|
|
152
|
+
b = (1 - price) / price
|
|
153
|
+
q = 1 - p
|
|
154
|
+
kelly_f = (p * b - q) / b
|
|
155
|
+
adjusted_f = kelly_f * kelly_fraction
|
|
156
|
+
|
|
157
|
+
max_bet = bankroll * max_position_pct
|
|
158
|
+
bet_size = min(bankroll * adjusted_f, max_bet)
|
|
159
|
+
|
|
160
|
+
contracts = max(1, int(bet_size / price))
|
|
161
|
+
cost = contracts * price
|
|
162
|
+
fee = kalshi_fee(contracts, price)
|
|
163
|
+
|
|
164
|
+
if cost + fee > bankroll:
|
|
165
|
+
contracts = max(1, int((bankroll - 0.01) / (price + kalshi_fee(1, price) / 1)))
|
|
166
|
+
cost = contracts * price
|
|
167
|
+
fee = kalshi_fee(contracts, price)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"contracts": contracts,
|
|
171
|
+
"edge": round(edge, 4),
|
|
172
|
+
"kelly_f": round(kelly_f, 4),
|
|
173
|
+
"adjusted_f": round(adjusted_f, 4),
|
|
174
|
+
"bet_size": round(bet_size, 2),
|
|
175
|
+
"cost": round(cost, 2),
|
|
176
|
+
"fee": round(fee, 4),
|
|
177
|
+
"total_cost": round(cost + fee, 2),
|
|
178
|
+
"expected_value": round(contracts * edge, 2),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- Risk checks ---
|
|
183
|
+
|
|
184
|
+
def check_risk(portfolio: Portfolio, ticker: str, cost: float) -> dict:
|
|
185
|
+
"""Run risk checks before a trade."""
|
|
186
|
+
issues = []
|
|
187
|
+
max_pos_pct = float(os.getenv("MAX_POSITION_PCT", "0.30"))
|
|
188
|
+
min_edge = float(os.getenv("MIN_EDGE", "0.10"))
|
|
189
|
+
|
|
190
|
+
if cost > portfolio.bankroll * max_pos_pct:
|
|
191
|
+
issues.append(f"Position size ${cost:.2f} exceeds {max_pos_pct:.0%} of bankroll ${portfolio.bankroll:.2f}")
|
|
192
|
+
|
|
193
|
+
if cost > portfolio.bankroll:
|
|
194
|
+
issues.append(f"Insufficient bankroll: need ${cost:.2f}, have ${portfolio.bankroll:.2f}")
|
|
195
|
+
|
|
196
|
+
if len(portfolio.open_positions) >= 5:
|
|
197
|
+
issues.append(f"Max 5 concurrent positions reached ({len(portfolio.open_positions)} open)")
|
|
198
|
+
|
|
199
|
+
existing = [t for t in portfolio.open_positions if t.ticker == ticker]
|
|
200
|
+
if existing:
|
|
201
|
+
issues.append(f"Already have open position on {ticker}")
|
|
202
|
+
|
|
203
|
+
return {"ok": len(issues) == 0, "issues": issues}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --- Trade execution ---
|
|
207
|
+
|
|
208
|
+
async def execute_trade(
|
|
209
|
+
ticker: str,
|
|
210
|
+
side: str,
|
|
211
|
+
contracts: int,
|
|
212
|
+
estimated_prob: float,
|
|
213
|
+
confidence: str,
|
|
214
|
+
reasoning: str,
|
|
215
|
+
news_trigger: str = "",
|
|
216
|
+
sources: list[str] | None = None,
|
|
217
|
+
) -> dict:
|
|
218
|
+
from gossip.kalshi import get_market_detail, get_orderbook as fetch_orderbook, kalshi_fee as kfee
|
|
219
|
+
|
|
220
|
+
detail = await get_market_detail(ticker)
|
|
221
|
+
market = detail.get("market", {})
|
|
222
|
+
if not market or "error" in detail:
|
|
223
|
+
return {"error": f"Market {ticker} not found"}
|
|
224
|
+
|
|
225
|
+
title = market.get("title", "?")
|
|
226
|
+
category = market.get("category", "?")
|
|
227
|
+
|
|
228
|
+
# Use orderbook for real best bid/ask, fall back to market summary
|
|
229
|
+
orderbook = detail.get("orderbook", {})
|
|
230
|
+
if not orderbook:
|
|
231
|
+
orderbook = await fetch_orderbook(ticker)
|
|
232
|
+
|
|
233
|
+
# Orderbook uses yes_dollars/no_dollars (each entry: [price_str, quantity_str])
|
|
234
|
+
yes_bids = orderbook.get("yes_dollars", orderbook.get("yes", [])) if orderbook else []
|
|
235
|
+
no_bids = orderbook.get("no_dollars", orderbook.get("no", [])) if orderbook else []
|
|
236
|
+
|
|
237
|
+
# Market summary prices
|
|
238
|
+
summary_bid = float(market.get("yes_bid_dollars", "0") or "0")
|
|
239
|
+
summary_ask = float(market.get("yes_ask_dollars", "0") or "0")
|
|
240
|
+
|
|
241
|
+
# Best bid/ask from orderbook — prices are already in dollars (e.g. "0.47")
|
|
242
|
+
if yes_bids:
|
|
243
|
+
ob_yes_bid = max(float(b[0]) for b in yes_bids)
|
|
244
|
+
else:
|
|
245
|
+
ob_yes_bid = summary_bid
|
|
246
|
+
|
|
247
|
+
if no_bids:
|
|
248
|
+
best_no_bid = max(float(b[0]) for b in no_bids)
|
|
249
|
+
ob_yes_ask = round(1.0 - best_no_bid, 4) # NO bid of 0.53 = YES ask of 0.47
|
|
250
|
+
else:
|
|
251
|
+
ob_yes_ask = summary_ask
|
|
252
|
+
|
|
253
|
+
bid = ob_yes_bid if ob_yes_bid > 0 else summary_bid
|
|
254
|
+
ask = ob_yes_ask if ob_yes_ask > 0 else summary_ask
|
|
255
|
+
|
|
256
|
+
log(f"TRADE PRICING for {ticker}: yes_bid={bid:.4f} yes_ask={ask:.4f} (summary: bid={summary_bid:.4f} ask={summary_ask:.4f})")
|
|
257
|
+
|
|
258
|
+
if side == "yes":
|
|
259
|
+
entry_price = ask
|
|
260
|
+
edge = estimated_prob - ask
|
|
261
|
+
else:
|
|
262
|
+
entry_price = 1.0 - bid
|
|
263
|
+
edge = (1 - estimated_prob) - (1 - bid)
|
|
264
|
+
|
|
265
|
+
if entry_price <= 0 or entry_price >= 1:
|
|
266
|
+
return {"error": f"Invalid entry price {entry_price:.4f} for {side} side (bid={bid:.4f} ask={ask:.4f})"}
|
|
267
|
+
|
|
268
|
+
cost = entry_price * contracts
|
|
269
|
+
fee = kalshi_fee(contracts, entry_price)
|
|
270
|
+
|
|
271
|
+
portfolio = load_portfolio()
|
|
272
|
+
|
|
273
|
+
risk = check_risk(portfolio, ticker, cost + fee)
|
|
274
|
+
if not risk["ok"]:
|
|
275
|
+
return {"error": "Risk check failed", "issues": risk["issues"]}
|
|
276
|
+
|
|
277
|
+
price_detail = f"yes_bid={bid:.4f} yes_ask={ask:.4f} | entry={entry_price:.4f} ({side})"
|
|
278
|
+
full_reasoning = f"{reasoning}\n[Prices at execution: {price_detail}]"
|
|
279
|
+
|
|
280
|
+
trade = Trade(
|
|
281
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
282
|
+
ticker=ticker,
|
|
283
|
+
title=title,
|
|
284
|
+
category=category,
|
|
285
|
+
side=side,
|
|
286
|
+
action="buy",
|
|
287
|
+
contracts=contracts,
|
|
288
|
+
entry_price=round(entry_price, 4),
|
|
289
|
+
cost=round(cost, 2),
|
|
290
|
+
fee=round(fee, 4),
|
|
291
|
+
estimated_prob=estimated_prob,
|
|
292
|
+
edge=round(edge, 4),
|
|
293
|
+
confidence=confidence,
|
|
294
|
+
reasoning=full_reasoning,
|
|
295
|
+
news_trigger=news_trigger,
|
|
296
|
+
sources=sources or [],
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Live execution if enabled
|
|
300
|
+
live_trading = os.getenv("LIVE_TRADING", "false").lower() == "true"
|
|
301
|
+
order_result = None
|
|
302
|
+
if live_trading:
|
|
303
|
+
from gossip.kalshi import place_order
|
|
304
|
+
price_cents = int(entry_price * 100)
|
|
305
|
+
order_result = await place_order(
|
|
306
|
+
ticker=ticker,
|
|
307
|
+
action="buy",
|
|
308
|
+
side=side,
|
|
309
|
+
count=contracts,
|
|
310
|
+
price_cents=price_cents,
|
|
311
|
+
order_type="limit",
|
|
312
|
+
)
|
|
313
|
+
if "error" in order_result:
|
|
314
|
+
return {"error": f"Live order failed: {order_result}", "mode": "live"}
|
|
315
|
+
log(f"LIVE ORDER placed: {order_result}")
|
|
316
|
+
|
|
317
|
+
portfolio.trades.append(trade)
|
|
318
|
+
portfolio.bankroll = round(portfolio.bankroll - cost - fee, 2)
|
|
319
|
+
portfolio.total_trades += 1
|
|
320
|
+
save_portfolio(portfolio)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
db = get_db()
|
|
324
|
+
db.insert_trade(
|
|
325
|
+
ticker=ticker, title=title, category=category, side=side,
|
|
326
|
+
contracts=contracts, entry_price=round(entry_price, 4),
|
|
327
|
+
cost=round(cost, 2), fee=round(fee, 4),
|
|
328
|
+
estimated_prob=estimated_prob, edge=round(edge, 4),
|
|
329
|
+
confidence=confidence, reasoning=full_reasoning,
|
|
330
|
+
news_trigger=news_trigger, sources=sources or [],
|
|
331
|
+
)
|
|
332
|
+
except Exception as e:
|
|
333
|
+
log(f"DB write failed (trade still recorded in JSON): {e}")
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
"status": "executed",
|
|
337
|
+
"mode": "live" if live_trading else "paper",
|
|
338
|
+
"order": order_result,
|
|
339
|
+
"ticker": ticker,
|
|
340
|
+
"side": side,
|
|
341
|
+
"contracts": contracts,
|
|
342
|
+
"entry_price": trade.entry_price,
|
|
343
|
+
"cost": trade.cost,
|
|
344
|
+
"fee": trade.fee,
|
|
345
|
+
"edge": trade.edge,
|
|
346
|
+
"yes_bid": bid,
|
|
347
|
+
"yes_ask": ask,
|
|
348
|
+
"summary_bid": summary_bid,
|
|
349
|
+
"summary_ask": summary_ask,
|
|
350
|
+
"bankroll_remaining": portfolio.bankroll,
|
|
351
|
+
"title": title,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def exit_position(ticker: str, reasoning: str) -> dict:
|
|
356
|
+
from gossip.kalshi import get_market_detail
|
|
357
|
+
|
|
358
|
+
portfolio = load_portfolio()
|
|
359
|
+
open_trade = None
|
|
360
|
+
for t in reversed(portfolio.trades):
|
|
361
|
+
if t.ticker == ticker and not t.settled:
|
|
362
|
+
open_trade = t
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
if not open_trade:
|
|
366
|
+
return {"error": f"No open position on {ticker}"}
|
|
367
|
+
|
|
368
|
+
detail = await get_market_detail(ticker)
|
|
369
|
+
market = detail.get("market", {})
|
|
370
|
+
bid = float(market.get("yes_bid_dollars", "0") or "0")
|
|
371
|
+
ask = float(market.get("yes_ask_dollars", "0") or "0")
|
|
372
|
+
|
|
373
|
+
if open_trade.side == "yes":
|
|
374
|
+
exit_price = bid
|
|
375
|
+
else:
|
|
376
|
+
exit_price = 1.0 - ask
|
|
377
|
+
|
|
378
|
+
pnl = round((exit_price - open_trade.entry_price) * open_trade.contracts, 2)
|
|
379
|
+
|
|
380
|
+
open_trade.settled = True
|
|
381
|
+
open_trade.outcome = "win" if pnl > 0 else "loss"
|
|
382
|
+
open_trade.pnl = pnl
|
|
383
|
+
open_trade.exit_reasoning = reasoning
|
|
384
|
+
|
|
385
|
+
portfolio.bankroll = round(portfolio.bankroll + (exit_price * open_trade.contracts), 2)
|
|
386
|
+
portfolio.total_pnl = round(portfolio.total_pnl + pnl, 2)
|
|
387
|
+
if pnl > 0:
|
|
388
|
+
portfolio.wins += 1
|
|
389
|
+
else:
|
|
390
|
+
portfolio.losses += 1
|
|
391
|
+
|
|
392
|
+
save_portfolio(portfolio)
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
"status": "exited",
|
|
396
|
+
"ticker": ticker,
|
|
397
|
+
"pnl": pnl,
|
|
398
|
+
"exit_price": exit_price,
|
|
399
|
+
"bankroll": portfolio.bankroll,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def settle_market(ticker: str, outcome_yes: bool) -> dict:
|
|
404
|
+
portfolio = load_portfolio()
|
|
405
|
+
|
|
406
|
+
for t in reversed(portfolio.trades):
|
|
407
|
+
if t.ticker == ticker and not t.settled:
|
|
408
|
+
t.settled = True
|
|
409
|
+
won = (t.side == "yes" and outcome_yes) or (t.side == "no" and not outcome_yes)
|
|
410
|
+
if won:
|
|
411
|
+
t.outcome = "win"
|
|
412
|
+
t.pnl = round((1.0 - t.entry_price) * t.contracts, 2)
|
|
413
|
+
portfolio.wins += 1
|
|
414
|
+
else:
|
|
415
|
+
t.outcome = "loss"
|
|
416
|
+
t.pnl = round(-t.entry_price * t.contracts, 2)
|
|
417
|
+
portfolio.losses += 1
|
|
418
|
+
|
|
419
|
+
portfolio.total_pnl = round(portfolio.total_pnl + t.pnl, 2)
|
|
420
|
+
portfolio.bankroll = round(portfolio.bankroll + t.entry_price * t.contracts + t.pnl, 2)
|
|
421
|
+
save_portfolio(portfolio)
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
"status": "settled",
|
|
425
|
+
"ticker": ticker,
|
|
426
|
+
"outcome": "yes" if outcome_yes else "no",
|
|
427
|
+
"result": t.outcome,
|
|
428
|
+
"pnl": t.pnl,
|
|
429
|
+
"bankroll": portfolio.bankroll,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {"error": f"No open trade on {ticker}"}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
async def check_settlements() -> list[dict]:
|
|
436
|
+
"""Check if any open positions have resolved on Kalshi and auto-settle them."""
|
|
437
|
+
from gossip.kalshi import get_market_detail
|
|
438
|
+
|
|
439
|
+
portfolio = load_portfolio()
|
|
440
|
+
settled = []
|
|
441
|
+
for t in portfolio.trades:
|
|
442
|
+
if t.settled:
|
|
443
|
+
continue
|
|
444
|
+
detail = await get_market_detail(t.ticker)
|
|
445
|
+
market = detail.get("market", {})
|
|
446
|
+
result = market.get("result", "")
|
|
447
|
+
status = market.get("status", "")
|
|
448
|
+
if result in ("yes", "no") or status == "settled":
|
|
449
|
+
outcome_yes = result == "yes"
|
|
450
|
+
won = (t.side == "yes" and outcome_yes) or (t.side == "no" and not outcome_yes)
|
|
451
|
+
t.settled = True
|
|
452
|
+
if won:
|
|
453
|
+
t.outcome = "win"
|
|
454
|
+
t.pnl = round((1.0 - t.entry_price) * t.contracts, 2)
|
|
455
|
+
portfolio.wins += 1
|
|
456
|
+
else:
|
|
457
|
+
t.outcome = "loss"
|
|
458
|
+
t.pnl = round(-t.entry_price * t.contracts, 2)
|
|
459
|
+
portfolio.losses += 1
|
|
460
|
+
portfolio.total_pnl = round(portfolio.total_pnl + t.pnl, 2)
|
|
461
|
+
portfolio.bankroll = round(portfolio.bankroll + t.entry_price * t.contracts + t.pnl, 2)
|
|
462
|
+
settled.append({
|
|
463
|
+
"ticker": t.ticker,
|
|
464
|
+
"result": result,
|
|
465
|
+
"outcome": t.outcome,
|
|
466
|
+
"pnl": t.pnl,
|
|
467
|
+
})
|
|
468
|
+
log(f"AUTO-SETTLED {t.ticker}: {t.outcome} (pnl: {t.pnl:+.2f})")
|
|
469
|
+
|
|
470
|
+
if settled:
|
|
471
|
+
save_portfolio(portfolio)
|
|
472
|
+
return settled
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def get_position_prices() -> list[dict]:
|
|
476
|
+
"""Fetch current prices for all open positions and calculate unrealized P&L."""
|
|
477
|
+
from gossip.kalshi import get_market_detail
|
|
478
|
+
|
|
479
|
+
portfolio = load_portfolio()
|
|
480
|
+
positions = []
|
|
481
|
+
for t in portfolio.open_positions:
|
|
482
|
+
detail = await get_market_detail(t.ticker)
|
|
483
|
+
market = detail.get("market", {})
|
|
484
|
+
bid = float(market.get("yes_bid_dollars", "0") or "0")
|
|
485
|
+
ask = float(market.get("yes_ask_dollars", "0") or "0")
|
|
486
|
+
mid = (bid + ask) / 2 if bid > 0 or ask > 0 else 0
|
|
487
|
+
|
|
488
|
+
if t.side == "yes":
|
|
489
|
+
current_value = bid * t.contracts
|
|
490
|
+
mark_price = bid
|
|
491
|
+
else:
|
|
492
|
+
current_value = (1.0 - ask) * t.contracts
|
|
493
|
+
mark_price = 1.0 - ask
|
|
494
|
+
|
|
495
|
+
unrealized_pnl = round(current_value - t.cost, 2)
|
|
496
|
+
positions.append({
|
|
497
|
+
"ticker": t.ticker,
|
|
498
|
+
"title": t.title,
|
|
499
|
+
"side": t.side,
|
|
500
|
+
"contracts": t.contracts,
|
|
501
|
+
"entry_price": t.entry_price,
|
|
502
|
+
"mark_price": round(mark_price, 4),
|
|
503
|
+
"mid": round(mid, 4),
|
|
504
|
+
"cost": t.cost,
|
|
505
|
+
"current_value": round(current_value, 2),
|
|
506
|
+
"unrealized_pnl": unrealized_pnl,
|
|
507
|
+
"pnl_pct": round(unrealized_pnl / t.cost * 100, 1) if t.cost > 0 else 0,
|
|
508
|
+
"status": market.get("status", ""),
|
|
509
|
+
"result": market.get("result", ""),
|
|
510
|
+
})
|
|
511
|
+
return positions
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# --- CLI ---
|
|
515
|
+
|
|
516
|
+
async def main():
|
|
517
|
+
parser = argparse.ArgumentParser(description="Open Trademaxxxing engine")
|
|
518
|
+
sub = parser.add_subparsers(dest="command")
|
|
519
|
+
|
|
520
|
+
sub.add_parser("portfolio", help="Show portfolio")
|
|
521
|
+
|
|
522
|
+
trade_p = sub.add_parser("trade", help="Execute paper trade")
|
|
523
|
+
trade_p.add_argument("ticker")
|
|
524
|
+
trade_p.add_argument("--side", choices=["yes", "no"], required=True)
|
|
525
|
+
trade_p.add_argument("--contracts", type=int, default=None)
|
|
526
|
+
trade_p.add_argument("--estimate", type=float, required=True)
|
|
527
|
+
trade_p.add_argument("--confidence", choices=["low", "medium", "high"], default="medium")
|
|
528
|
+
trade_p.add_argument("--reasoning", type=str, required=True)
|
|
529
|
+
trade_p.add_argument("--news", type=str, default="")
|
|
530
|
+
trade_p.add_argument("--sources", type=str, default="")
|
|
531
|
+
|
|
532
|
+
exit_p = sub.add_parser("exit", help="Exit position")
|
|
533
|
+
exit_p.add_argument("ticker")
|
|
534
|
+
exit_p.add_argument("--reasoning", type=str, required=True)
|
|
535
|
+
|
|
536
|
+
settle_p = sub.add_parser("settle", help="Settle resolved market")
|
|
537
|
+
settle_p.add_argument("ticker")
|
|
538
|
+
settle_p.add_argument("--outcome", choices=["yes", "no"], required=True)
|
|
539
|
+
|
|
540
|
+
size_p = sub.add_parser("size", help="Calculate position size (dry run)")
|
|
541
|
+
size_p.add_argument("ticker")
|
|
542
|
+
size_p.add_argument("--estimate", type=float, required=True)
|
|
543
|
+
size_p.add_argument("--side", choices=["yes", "no"], default="yes")
|
|
544
|
+
|
|
545
|
+
sub.add_parser("history", help="Trade history")
|
|
546
|
+
sub.add_parser("check-settled", help="Auto-settle resolved markets")
|
|
547
|
+
sub.add_parser("prices", help="Current prices + unrealized P&L for open positions")
|
|
548
|
+
|
|
549
|
+
args = parser.parse_args()
|
|
550
|
+
|
|
551
|
+
if args.command == "portfolio":
|
|
552
|
+
p = load_portfolio()
|
|
553
|
+
result = {
|
|
554
|
+
"bankroll": p.bankroll,
|
|
555
|
+
"total_pnl": p.total_pnl,
|
|
556
|
+
"total_trades": p.total_trades,
|
|
557
|
+
"wins": p.wins,
|
|
558
|
+
"losses": p.losses,
|
|
559
|
+
"win_rate": round(p.win_rate, 3),
|
|
560
|
+
"deployed_capital": round(p.deployed_capital, 2),
|
|
561
|
+
"open_positions": [
|
|
562
|
+
{
|
|
563
|
+
"ticker": t.ticker,
|
|
564
|
+
"title": t.title,
|
|
565
|
+
"side": t.side,
|
|
566
|
+
"contracts": t.contracts,
|
|
567
|
+
"entry_price": t.entry_price,
|
|
568
|
+
"edge": t.edge,
|
|
569
|
+
"confidence": t.confidence,
|
|
570
|
+
"reasoning": t.reasoning[:200],
|
|
571
|
+
}
|
|
572
|
+
for t in p.open_positions
|
|
573
|
+
],
|
|
574
|
+
}
|
|
575
|
+
print(json.dumps(result, indent=2))
|
|
576
|
+
|
|
577
|
+
elif args.command == "trade":
|
|
578
|
+
if args.contracts is None:
|
|
579
|
+
from gossip.kalshi import get_market_detail
|
|
580
|
+
detail = await get_market_detail(args.ticker)
|
|
581
|
+
market = detail.get("market", {})
|
|
582
|
+
bid = float(market.get("yes_bid_dollars", "0") or "0")
|
|
583
|
+
ask = float(market.get("yes_ask_dollars", "0") or "0")
|
|
584
|
+
market_price = ask if args.side == "yes" else (1 - bid)
|
|
585
|
+
p = load_portfolio()
|
|
586
|
+
sizing = kelly_size(args.estimate, market_price, p.bankroll, args.side)
|
|
587
|
+
contracts = sizing["contracts"]
|
|
588
|
+
else:
|
|
589
|
+
contracts = args.contracts
|
|
590
|
+
|
|
591
|
+
sources = [s.strip() for s in args.sources.split(",") if s.strip()] if args.sources else []
|
|
592
|
+
result = await execute_trade(
|
|
593
|
+
ticker=args.ticker,
|
|
594
|
+
side=args.side,
|
|
595
|
+
contracts=contracts,
|
|
596
|
+
estimated_prob=args.estimate,
|
|
597
|
+
confidence=args.confidence,
|
|
598
|
+
reasoning=args.reasoning,
|
|
599
|
+
news_trigger=args.news,
|
|
600
|
+
sources=sources,
|
|
601
|
+
)
|
|
602
|
+
print(json.dumps(result, indent=2))
|
|
603
|
+
|
|
604
|
+
elif args.command == "exit":
|
|
605
|
+
result = await exit_position(args.ticker, args.reasoning)
|
|
606
|
+
print(json.dumps(result, indent=2))
|
|
607
|
+
|
|
608
|
+
elif args.command == "settle":
|
|
609
|
+
result = settle_market(args.ticker, args.outcome == "yes")
|
|
610
|
+
print(json.dumps(result, indent=2))
|
|
611
|
+
|
|
612
|
+
elif args.command == "size":
|
|
613
|
+
from gossip.kalshi import get_market_detail
|
|
614
|
+
detail = await get_market_detail(args.ticker)
|
|
615
|
+
market = detail.get("market", {})
|
|
616
|
+
bid = float(market.get("yes_bid_dollars", "0") or "0")
|
|
617
|
+
ask = float(market.get("yes_ask_dollars", "0") or "0")
|
|
618
|
+
market_price = ask if args.side == "yes" else (1 - bid)
|
|
619
|
+
p = load_portfolio()
|
|
620
|
+
result = kelly_size(args.estimate, market_price, p.bankroll, args.side)
|
|
621
|
+
result["ticker"] = args.ticker
|
|
622
|
+
result["market_price"] = market_price
|
|
623
|
+
result["estimated_prob"] = args.estimate
|
|
624
|
+
result["bankroll"] = p.bankroll
|
|
625
|
+
print(json.dumps(result, indent=2))
|
|
626
|
+
|
|
627
|
+
elif args.command == "history":
|
|
628
|
+
p = load_portfolio()
|
|
629
|
+
trades = [asdict(t) for t in p.trades[-20:]]
|
|
630
|
+
print(json.dumps(trades, indent=2))
|
|
631
|
+
|
|
632
|
+
elif args.command == "check-settled":
|
|
633
|
+
settled = await check_settlements()
|
|
634
|
+
print(json.dumps({"settled": settled, "count": len(settled)}, indent=2))
|
|
635
|
+
|
|
636
|
+
elif args.command == "prices":
|
|
637
|
+
positions = await get_position_prices()
|
|
638
|
+
total_unrealized = sum(p["unrealized_pnl"] for p in positions)
|
|
639
|
+
print(json.dumps({"positions": positions, "total_unrealized_pnl": round(total_unrealized, 2)}, indent=2))
|
|
640
|
+
|
|
641
|
+
else:
|
|
642
|
+
parser.print_help()
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
if __name__ == "__main__":
|
|
646
|
+
asyncio.run(main())
|