RayMelius Claude Opus 4.6 commited on
Commit
f25ca9f
Β·
1 Parent(s): 4ff38f2

Add StockEx trading integration for player agents

Browse files

- New API endpoints: /stockex/market, /stockex/leaderboard,
/stockex/portfolio/{id}, /stockex/order
- Player agents can browse market data, view portfolios, and place
buy/sell orders on StockEx clearing house
- Orders recorded as life events and memories for the agent
- Web UI: StockEx button in player panel opens trading modal with
live market data, portfolio view, and order form

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. src/soci/api/routes.py +103 -0
  2. web/index.html +152 -0
src/soci/api/routes.py CHANGED
@@ -834,3 +834,106 @@ async def save_state(name: str = "manual_save"):
834
  from soci.persistence.snapshots import save_simulation
835
  await save_simulation(sim, db, name)
836
  return {"status": "saved", "name": name, "tick": sim.clock.total_ticks}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
  from soci.persistence.snapshots import save_simulation
835
  await save_simulation(sim, db, name)
836
  return {"status": "saved", "name": name, "tick": sim.clock.total_ticks}
837
+
838
+
839
+ # ── StockEx integration ──────────────────────────────────────────────────────
840
+
841
+ import os
842
+ import httpx
843
+
844
+ STOCKEX_URL = os.getenv("STOCKEX_URL", "https://raymelius-stockex.hf.space")
845
+ STOCKEX_API_KEY = os.getenv("STOCKEX_API_KEY", "soci-stockex-2024")
846
+
847
+
848
+ class StockExOrderRequest(BaseModel):
849
+ token: str # Soci player token
850
+ member_id: str # StockEx member ID (e.g. USR02)
851
+ symbol: str
852
+ side: str # BUY or SELL
853
+ quantity: int
854
+ price: float
855
+
856
+
857
+ @router.get("/stockex/market")
858
+ async def stockex_market():
859
+ """Get current market data (best bid/offer) from StockEx."""
860
+ async with httpx.AsyncClient(timeout=10.0) as client:
861
+ resp = await client.get(f"{STOCKEX_URL}/ch/api/market")
862
+ if resp.status_code != 200:
863
+ raise HTTPException(status_code=502, detail="StockEx market unavailable")
864
+ return resp.json()
865
+
866
+
867
+ @router.get("/stockex/leaderboard")
868
+ async def stockex_leaderboard():
869
+ """Get StockEx clearing house leaderboard."""
870
+ async with httpx.AsyncClient(timeout=10.0) as client:
871
+ resp = await client.get(f"{STOCKEX_URL}/ch/api/leaderboard")
872
+ if resp.status_code != 200:
873
+ raise HTTPException(status_code=502, detail="StockEx unavailable")
874
+ return resp.json()
875
+
876
+
877
+ @router.get("/stockex/portfolio/{member_id}")
878
+ async def stockex_portfolio(member_id: str):
879
+ """Get a StockEx member's portfolio."""
880
+ async with httpx.AsyncClient(timeout=10.0) as client:
881
+ resp = await client.get(f"{STOCKEX_URL}/ch/api/member/{member_id}")
882
+ if resp.status_code != 200:
883
+ raise HTTPException(status_code=502, detail="StockEx member not found")
884
+ return resp.json()
885
+
886
+
887
+ @router.post("/stockex/order")
888
+ async def stockex_order(req: StockExOrderRequest):
889
+ """Place a trade on StockEx on behalf of a Soci player agent."""
890
+ from soci.api.server import get_simulation
891
+ sim = get_simulation()
892
+
893
+ # Verify Soci player token
894
+ from soci.persistence.database import Database
895
+ db = Database()
896
+ user = await db.get_user_by_token(req.token)
897
+ if not user or not user.get("agent_id"):
898
+ raise HTTPException(status_code=401, detail="Invalid token")
899
+
900
+ agent = sim.agents.get(user["agent_id"])
901
+ if not agent:
902
+ raise HTTPException(status_code=404, detail="Player agent not found")
903
+
904
+ # Place order on StockEx via API key
905
+ order_data = {
906
+ "api_key": STOCKEX_API_KEY,
907
+ "member_id": req.member_id,
908
+ "symbol": req.symbol.upper(),
909
+ "side": req.side.upper(),
910
+ "quantity": req.quantity,
911
+ "price": req.price,
912
+ }
913
+
914
+ async with httpx.AsyncClient(timeout=10.0) as client:
915
+ resp = await client.post(f"{STOCKEX_URL}/ch/api/order", json=order_data)
916
+ result = resp.json()
917
+
918
+ if resp.status_code != 200:
919
+ raise HTTPException(status_code=resp.status_code, detail=result.get("error", "Order failed"))
920
+
921
+ # Record as life event for the agent
922
+ side_str = req.side.upper()
923
+ agent.add_life_event(
924
+ day=sim.clock.day, tick=sim.clock.total_ticks,
925
+ event_type="achievement",
926
+ description=f"Traded on StockEx: {side_str} {req.quantity} {req.symbol.upper()} @ €{req.price:.2f}",
927
+ )
928
+ agent.add_observation(
929
+ tick=sim.clock.total_ticks, day=sim.clock.day,
930
+ time_str=sim.clock.time_str,
931
+ content=f"I placed a {side_str} order for {req.quantity} shares of {req.symbol.upper()} at €{req.price:.2f} on StockEx.",
932
+ importance=7,
933
+ )
934
+
935
+ return {
936
+ "status": "ok",
937
+ "cl_ord_id": result.get("cl_ord_id"),
938
+ "detail": f"{side_str} {req.quantity} {req.symbol.upper()} @ €{req.price:.2f}",
939
+ }
web/index.html CHANGED
@@ -325,6 +325,7 @@
325
  <div class="pp-actions">
326
  <button class="pp-btn" onclick="openProfileEditor()">Edit Profile</button>
327
  <button class="pp-btn" onclick="openPlansModal()">My Plans</button>
 
328
  </div>
329
  </div>
330
  <div id="agent-detail"></div>
@@ -408,6 +409,53 @@
408
  </div>
409
  </div>
410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  <script>
412
  // ============================================================
413
  // CONFIG
@@ -3364,6 +3412,110 @@ async function addPlan() {
3364
  } catch(e){}
3365
  }
3366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3367
  // Chat with an NPC
3368
  function openChat(targetId) {
3369
  chatTargetId = targetId;
 
325
  <div class="pp-actions">
326
  <button class="pp-btn" onclick="openProfileEditor()">Edit Profile</button>
327
  <button class="pp-btn" onclick="openPlansModal()">My Plans</button>
328
+ <button class="pp-btn" onclick="openStockExModal()" style="background:#1a3a2e;border-color:#2a6a4e">StockEx</button>
329
  </div>
330
  </div>
331
  <div id="agent-detail"></div>
 
409
  </div>
410
  </div>
411
 
412
+ <!-- StockEx Trading modal -->
413
+ <div id="stockex-modal" class="modal-overlay" style="display:none">
414
+ <div class="modal-box" style="max-width:480px">
415
+ <h2 style="color:#4ecca3">StockEx Trading</h2>
416
+ <div id="stockex-status" style="font-size:11px;color:#888;margin-bottom:8px"></div>
417
+
418
+ <div id="stockex-portfolio" style="font-size:11px;color:#a0a0c0;margin-bottom:10px;max-height:120px;overflow-y:auto"></div>
419
+
420
+ <div class="modal-row">
421
+ <div style="flex:1">
422
+ <label>Member ID</label>
423
+ <input type="text" id="sx-member" placeholder="USR02" value="USR02" style="text-transform:uppercase">
424
+ </div>
425
+ <div style="flex:1">
426
+ <label>Symbol</label>
427
+ <select id="sx-symbol" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px">
428
+ <option>Loading...</option>
429
+ </select>
430
+ </div>
431
+ </div>
432
+ <div class="modal-row">
433
+ <div style="flex:1">
434
+ <label>Side</label>
435
+ <select id="sx-side" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px">
436
+ <option value="BUY">BUY</option>
437
+ <option value="SELL">SELL</option>
438
+ </select>
439
+ </div>
440
+ <div style="flex:1">
441
+ <label>Quantity</label>
442
+ <input type="number" id="sx-qty" min="1" value="100">
443
+ </div>
444
+ <div style="flex:1">
445
+ <label>Price</label>
446
+ <input type="number" id="sx-price" step="0.01" min="0.01" value="0">
447
+ </div>
448
+ </div>
449
+ <div id="sx-market-info" style="font-size:10px;color:#555;margin:4px 0"></div>
450
+ <div id="sx-result" style="font-size:11px;margin:6px 0;min-height:18px"></div>
451
+ <div class="modal-actions">
452
+ <button class="btn-secondary" onclick="document.getElementById('stockex-modal').style.display='none'">Close</button>
453
+ <button class="btn-secondary" onclick="loadStockExPortfolio()">Refresh</button>
454
+ <button class="btn-primary" onclick="submitStockExOrder()" style="background:#1a6a3e">Place Order</button>
455
+ </div>
456
+ </div>
457
+ </div>
458
+
459
  <script>
460
  // ============================================================
461
  // CONFIG
 
3412
  } catch(e){}
3413
  }
3414
 
3415
+ // ── StockEx Integration ──────────────────────────────────────────
3416
+ let stockexMarketData = {};
3417
+
3418
+ async function openStockExModal() {
3419
+ if (!playerToken) return;
3420
+ document.getElementById('stockex-modal').style.display = 'flex';
3421
+ document.getElementById('sx-result').innerHTML = '';
3422
+ // Load market data and symbols
3423
+ try {
3424
+ const res = await fetch(`${API_BASE}/stockex/market`);
3425
+ if (res.ok) {
3426
+ stockexMarketData = await res.json();
3427
+ const sel = document.getElementById('sx-symbol');
3428
+ const symbols = Object.keys(stockexMarketData).sort();
3429
+ sel.innerHTML = symbols.map(s => `<option value="${s}">${s}</option>`).join('');
3430
+ sel.value = 'EXAE'; // Default
3431
+ updateStockExPrice();
3432
+ }
3433
+ } catch(e) {
3434
+ document.getElementById('sx-market-info').textContent = 'Could not load market data';
3435
+ }
3436
+ loadStockExPortfolio();
3437
+ }
3438
+
3439
+ function updateStockExPrice() {
3440
+ const sym = document.getElementById('sx-symbol').value;
3441
+ const mkt = stockexMarketData[sym];
3442
+ if (mkt) {
3443
+ const bid = mkt.bid_price || 0;
3444
+ const ask = mkt.ask_price || 0;
3445
+ const mid = mkt.mid || ((bid + ask) / 2);
3446
+ document.getElementById('sx-price').value = mid.toFixed(2);
3447
+ document.getElementById('sx-market-info').textContent =
3448
+ `Bid: ${bid.toFixed(2)} | Ask: ${ask.toFixed(2)} | Mid: ${mid.toFixed(2)}`;
3449
+ }
3450
+ }
3451
+
3452
+ async function loadStockExPortfolio() {
3453
+ const mid = document.getElementById('sx-member').value.trim().toUpperCase();
3454
+ if (!mid) return;
3455
+ try {
3456
+ const res = await fetch(`${API_BASE}/stockex/portfolio/${mid}`);
3457
+ if (res.ok) {
3458
+ const data = await res.json();
3459
+ const cap = data.capital || 0;
3460
+ const pnl = data.pnl || 0;
3461
+ const pnlColor = pnl >= 0 ? '#4ecca3' : '#e74c3c';
3462
+ let html = `<div style="margin-bottom:4px"><b>${data.member_id}</b> Capital: <b>${cap.toFixed(2)}</b> | P&L: <span style="color:${pnlColor}">${pnl.toFixed(2)}</span></div>`;
3463
+ const holdings = data.holdings || [];
3464
+ if (holdings.length > 0) {
3465
+ html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
3466
+ for (const h of holdings) {
3467
+ const pnlH = h.unrealized_pnl || 0;
3468
+ const c = pnlH >= 0 ? '#4ecca3' : '#e74c3c';
3469
+ html += `<span style="padding:1px 4px;background:#1a1a3e;border-radius:3px;font-size:10px">${h.symbol}: ${h.quantity} <span style="color:${c}">(${pnlH > 0 ? '+' : ''}${pnlH.toFixed(0)})</span></span>`;
3470
+ }
3471
+ html += '</div>';
3472
+ }
3473
+ document.getElementById('stockex-portfolio').innerHTML = html;
3474
+ document.getElementById('stockex-status').textContent = `${holdings.length} positions | Obligation: ${data.obligation || 20}/day`;
3475
+ } else {
3476
+ document.getElementById('stockex-portfolio').innerHTML = '<span style="color:#e74c3c">Member not found</span>';
3477
+ }
3478
+ } catch(e) {
3479
+ document.getElementById('stockex-portfolio').innerHTML = '<span style="color:#e74c3c">Could not load portfolio</span>';
3480
+ }
3481
+ }
3482
+
3483
+ async function submitStockExOrder() {
3484
+ if (!playerToken) return;
3485
+ const resultEl = document.getElementById('sx-result');
3486
+ const body = {
3487
+ token: playerToken,
3488
+ member_id: document.getElementById('sx-member').value.trim().toUpperCase(),
3489
+ symbol: document.getElementById('sx-symbol').value,
3490
+ side: document.getElementById('sx-side').value,
3491
+ quantity: parseInt(document.getElementById('sx-qty').value) || 0,
3492
+ price: parseFloat(document.getElementById('sx-price').value) || 0,
3493
+ };
3494
+ if (body.quantity <= 0 || body.price <= 0) {
3495
+ resultEl.innerHTML = '<span style="color:#e74c3c">Invalid quantity or price</span>';
3496
+ return;
3497
+ }
3498
+ resultEl.innerHTML = '<span style="color:#888">Placing order...</span>';
3499
+ try {
3500
+ const res = await fetch(`${API_BASE}/stockex/order`, {
3501
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
3502
+ });
3503
+ const data = await res.json();
3504
+ if (res.ok) {
3505
+ resultEl.innerHTML = `<span style="color:#4ecca3">${esc(data.detail)} β€” ${data.cl_ord_id}</span>`;
3506
+ loadStockExPortfolio(); // Refresh
3507
+ } else {
3508
+ resultEl.innerHTML = `<span style="color:#e74c3c">${esc(data.detail || data.error || 'Order failed')}</span>`;
3509
+ }
3510
+ } catch(e) {
3511
+ resultEl.innerHTML = '<span style="color:#e74c3c">Connection error</span>';
3512
+ }
3513
+ }
3514
+
3515
+ // Update price when symbol changes
3516
+ document.getElementById('sx-symbol')?.addEventListener('change', updateStockExPrice);
3517
+ document.getElementById('sx-member')?.addEventListener('change', loadStockExPortfolio);
3518
+
3519
  // Chat with an NPC
3520
  function openChat(targetId) {
3521
  chatTargetId = targetId;