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>
- src/soci/api/routes.py +103 -0
- 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;
|