diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e3d76b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ diff --git a/PLATFORM_BLUEPRINT.md b/PLATFORM_BLUEPRINT.md new file mode 100644 index 0000000..853cc2c --- /dev/null +++ b/PLATFORM_BLUEPRINT.md @@ -0,0 +1,148 @@ +# OpenClaw Arena: Autonomous Token Creation and Agent-vs-Agent Trading on Solana + +## 1) Product Vision +OpenClaw Arena is an autonomous market where OpenClaw agents: +- ingest real-time information (news, social signals, on-chain data), +- launch short-lifecycle “meta tokens” with transparent rules, +- and trade these assets against one another in a permissioned competitive environment. + +Humans do not manually pick trades. They only: +1. fund agent vaults with SOL, +2. configure risk bounds, +3. monitor outcomes. + +## 2) High-Level Architecture + +### Core modules +1. **Agent Runtime Layer** + - Hosts each OpenClaw strategy agent in isolated execution environments. + - Enforces deterministic action interfaces: `observe`, `propose`, `act`, `explain`. + +2. **Data & Signal Ingestion Layer** + - News APIs, X/Reddit trend scrapers, Solana on-chain feeds, DEX liquidity telemetry. + - Normalizes all signals into a shared event schema. + - Publishes events to a stream (e.g., Redpanda/Kafka). + +3. **Token Factory Layer** + - Controlled token-launch pipeline for agents. + - Agent proposal includes: ticker, thesis, launch parameters, max supply, curve type, fee split, and expiry policy. + - Guardrail service validates proposals against policy. + +4. **Market Execution Layer** + - Bonding-curve launch venue + optional migration to DEX pools at milestones. + - Router abstraction for swaps and limit logic. + - Shared simulation and slippage-aware execution. + +5. **Risk, Governance, and Safety Layer** + - Global and per-agent limits (position size, daily loss cap, token launch quota). + - Circuit breakers on volatility, oracle anomalies, and liquidity shocks. + - “Kill switch” to pause all agent actions. + +6. **Scoring and Tournament Layer** + - Agent leaderboard based on risk-adjusted returns (Sharpe/Sortino-like), drawdown, and rule compliance. + - Epoch-based tournaments with transparent reward logic. + +7. **Observability Layer** + - Structured logs, action traces, signed agent rationales. + - Reproducible replay of decisions and state snapshots. + +## 3) Solana-Centric System Design + +### On-chain programs (recommended split) +- **Vault Program** + - Manages per-agent SOL and token balances. + - Enforces withdrawal and spend authorities. +- **Launch Program** + - Creates “meta tokens” under strict templates. + - Stores launch metadata, fee parameters, expiry fields. +- **Market Program** + - Bonding curve and internal matching primitives. + - Emits events consumed by analytics/scoring. +- **Risk Program (or guardian signer policy)** + - Rejects transactions exceeding guardrails. + +### Off-chain services +- Agent orchestration service (Kubernetes/Nomad). +- Strategy sandbox service (WASM/container jail). +- Event indexer + feature store. +- Backtesting engine with historical replay. +- API + dashboard for operators. + +## 4) Agent Lifecycle +1. **Bootstrap**: operator funds vault, assigns strategy profile. +2. **Observe**: agent consumes latest normalized signals. +3. **Hypothesize**: agent predicts emerging meta narratives. +4. **Propose**: + - launch token, or + - trade existing tokens. +5. **Policy check**: risk engine approves/rejects action. +6. **Execute**: signed transaction via controlled key path. +7. **Post-trade reflection**: store rationale + confidence + outcome. +8. **Score update**: leaderboard and adaptive throttling. + +## 5) Minimal Viable Product (MVP) + +### MVP scope (6-10 weeks) +- 10-20 agents with predefined strategy archetypes: + - momentum chaser, + - contrarian mean-reversion, + - sentiment/news follower, + - liquidity sniper. +- Token factory with one launch template. +- One bonding curve model. +- Hard limits per agent + global kill switch. +- Basic leaderboard and action audit UI. + +### MVP non-goals +- Open public permissionless agent onboarding. +- High-frequency cross-DEX arbitrage. +- Complex derivatives. + +## 6) Safety and Abuse Prevention +- Permissioned agent registry with identity and version pinning. +- Deterministic policy engine separate from model outputs. +- Prompt/strategy tamper detection and signed build artifacts. +- Mandatory cool-down periods between token launches. +- Market manipulation heuristics (self-wash patterns, circular trading). + +## 7) Economics and Incentive Design +- **Entry stake**: each agent starts with equal SOL allocation. +- **Fee model**: + - Launch fee, + - trading fee, + - optional performance fee for winning agents. +- **Rewards**: + - Epoch payouts for top risk-adjusted performers, + - penalty multipliers for breaking policy constraints. +- **Token lifecycle**: + - auto-expiry or migration criteria, + - stale token cleanup to reduce state bloat. + +## 8) Compliance and Legal Considerations (must-do) +- Jurisdictional review for autonomous trading system classification. +- Terms that clarify operator/agent responsibilities. +- Sanctions and AML screens for human depositors if public-facing. +- Transparent disclosure: agents are experimental and non-advisory. + +## 9) Recommended Build Order +1. Backtest simulator + event schema. +2. Risk policy engine and enforcement contract. +3. Agent runtime with 3 baseline strategies. +4. Token factory + bonding curve launch. +5. Live paper-trading mode. +6. Mainnet gated beta with low limits. + +## 10) First Technical Milestones +- **Milestone A**: deterministic simulation using historical Solana + news snapshots. +- **Milestone B**: launch/trade guardrails proven by invariant tests. +- **Milestone C**: 30-day paper tournament with reproducible logs. +- **Milestone D**: controlled real-SOL tournament with capped downside. + +## 11) Practical Next Step +Implement a paper-trading “Arena v0” before moving real funds: +- no real token minting, +- synthetic balances, +- full decision logging, +- and identical risk controls. + +If Arena v0 produces stable, auditable behavior, reuse the same interfaces for mainnet execution with stricter limits. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5c8b58 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# OpenClaw Arena v0 + +A starter implementation of the paper-trading arena described in `PLATFORM_BLUEPRINT.md`. + +## What is implemented + +- Replayed market feed from historical snapshot CSV data. +- Multiple autonomous agents that observe market snapshots and submit buy/sell/hold intents. +- Deterministic risk engine enforcing: + - max position size, + - max notional per trade, + - no shorting, + - minimum cash reserve. +- Token launch proposal flow where agents can propose new meta tokens. +- Launch policy guardrails enforcing: + - launch quota per agent, + - minimum confidence, + - ticker format, + - max initial supply, + - cooldown between launches. +- **Venue-level token pricing and cross-agent token inventory**: + - each approved launch creates a token market with reserves, + - all agents can buy/sell launched tokens, + - constant-product pricing updates per trade, + - each agent portfolio tracks per-token inventory, + - scoreboard includes token inventory by agent. +- Epoch simulation loop with decision/action logs, launch logs, and token-trade logs. + +## Quickstart + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e .[dev] +python -m openclaw_arena.main --steps 50 --seed 7 +``` + +Run using a custom snapshot file: + +```bash +python -m openclaw_arena.main --steps 120 --snapshot-path ./path/to/snapshots.csv +``` + +Expected CSV columns: + +- `step` +- `price` +- `momentum` +- `volatility` + +## Run tests + +```bash +pytest +``` + +## Next build targets + +- Add per-token risk limits (max exposure per agent/token). +- Plug in a real historical/news feature pipeline instead of static CSV snapshots. +- Stream logs to a persistent store for tournament analytics. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e8de53f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "openclaw-arena" +version = "0.1.0" +description = "Arena v0 simulation for autonomous OpenClaw agent trading" +requires-python = ">=3.10" +readme = "README.md" +authors = [ + { name = "OpenClaw Arena" } +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0" +] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.setuptools.package-data] +openclaw_arena = ["data/*.csv"] diff --git a/src/openclaw_arena/__init__.py b/src/openclaw_arena/__init__.py new file mode 100644 index 0000000..04e9a64 --- /dev/null +++ b/src/openclaw_arena/__init__.py @@ -0,0 +1,5 @@ +"""OpenClaw Arena v0 package.""" + +from .simulation import run_simulation + +__all__ = ["run_simulation"] diff --git a/src/openclaw_arena/agents.py b/src/openclaw_arena/agents.py new file mode 100644 index 0000000..359b114 --- /dev/null +++ b/src/openclaw_arena/agents.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass + +from .models import AgentAction, LaunchProposal, MarketState, Side, TokenOrder + + +class BaseAgent: + agent_id: str + + def act(self, state: MarketState) -> AgentAction: + raise NotImplementedError + + def propose_launch(self, state: MarketState) -> LaunchProposal | None: + return None + + def propose_token_order(self, state: MarketState, ticker: str, ref_price: float, inventory: float) -> TokenOrder | None: + return None + + +@dataclass(slots=True) +class MomentumAgent(BaseAgent): + agent_id: str + aggressiveness: float = 1.0 + + def act(self, state: MarketState) -> AgentAction: + if state.momentum > 0.001: + qty = max(0.0, self.aggressiveness * state.momentum * 100) + return AgentAction(self.agent_id, Side.BUY, qty, 0.7, "Positive momentum detected") + if state.momentum < -0.001: + qty = max(0.0, self.aggressiveness * abs(state.momentum) * 100) + return AgentAction(self.agent_id, Side.SELL, qty, 0.7, "Negative momentum detected") + return AgentAction(self.agent_id, Side.HOLD, 0.0, 0.3, "No clear momentum") + + def propose_launch(self, state: MarketState) -> LaunchProposal | None: + if state.momentum > 0.009: + return LaunchProposal( + agent_id=self.agent_id, + step=state.step, + ticker="MOMO", + thesis="Strong positive price impulse indicates short-lived momentum meta", + initial_supply=250_000, + confidence=0.74, + ) + return None + + def propose_token_order(self, state: MarketState, ticker: str, ref_price: float, inventory: float) -> TokenOrder | None: + if state.momentum > 0.003: + return TokenOrder(agent_id=self.agent_id, ticker=ticker, side=Side.BUY, quantity=50.0) + if state.momentum < -0.003 and inventory > 0: + return TokenOrder(agent_id=self.agent_id, ticker=ticker, side=Side.SELL, quantity=min(40.0, inventory)) + return None + + +@dataclass(slots=True) +class MeanReversionAgent(BaseAgent): + agent_id: str + anchor_price: float + + def act(self, state: MarketState) -> AgentAction: + deviation = (state.price - self.anchor_price) / self.anchor_price + if deviation > 0.03: + return AgentAction(self.agent_id, Side.SELL, deviation * 50, 0.65, "Price above anchor; mean reversion short-bias") + if deviation < -0.03: + return AgentAction(self.agent_id, Side.BUY, abs(deviation) * 50, 0.65, "Price below anchor; mean reversion long-bias") + return AgentAction(self.agent_id, Side.HOLD, 0.0, 0.35, "Price near anchor") + + def propose_token_order(self, state: MarketState, ticker: str, ref_price: float, inventory: float) -> TokenOrder | None: + deviation = (state.price - self.anchor_price) / self.anchor_price + if deviation < -0.01: + return TokenOrder(agent_id=self.agent_id, ticker=ticker, side=Side.BUY, quantity=30.0) + if deviation > 0.01 and inventory > 0: + return TokenOrder(agent_id=self.agent_id, ticker=ticker, side=Side.SELL, quantity=min(30.0, inventory)) + return None + + +@dataclass(slots=True) +class NoiseSentimentAgent(BaseAgent): + agent_id: str + seed: int + + def __post_init__(self) -> None: + self._rng = random.Random(self.seed) + + def act(self, state: MarketState) -> AgentAction: + sentiment = self._rng.uniform(-1, 1) + (state.momentum * 10) + if sentiment > 0.5: + return AgentAction(self.agent_id, Side.BUY, sentiment * 4, 0.55, "Synthetic sentiment bullish") + if sentiment < -0.5: + return AgentAction(self.agent_id, Side.SELL, abs(sentiment) * 4, 0.55, "Synthetic sentiment bearish") + return AgentAction(self.agent_id, Side.HOLD, 0.0, 0.25, "Sentiment neutral") + + def propose_launch(self, state: MarketState) -> LaunchProposal | None: + buzz = self._rng.uniform(0, 1) + abs(state.momentum) + if buzz > 0.92: + ticker = "META" if state.momentum >= 0 else "FADE" + thesis = "Synthetic social buzz spike detected" + return LaunchProposal( + agent_id=self.agent_id, + step=state.step, + ticker=ticker, + thesis=thesis, + initial_supply=300_000, + confidence=min(0.9, 0.6 + buzz / 3), + ) + return None + + def propose_token_order(self, state: MarketState, ticker: str, ref_price: float, inventory: float) -> TokenOrder | None: + toss = self._rng.uniform(-1, 1) + if toss > 0.4: + return TokenOrder(agent_id=self.agent_id, ticker=ticker, side=Side.BUY, quantity=20.0) + if toss < -0.4 and inventory > 0: + return TokenOrder(agent_id=self.agent_id, ticker=ticker, side=Side.SELL, quantity=min(20.0, inventory)) + return None diff --git a/src/openclaw_arena/data/historical_snapshots.csv b/src/openclaw_arena/data/historical_snapshots.csv new file mode 100644 index 0000000..ce71db0 --- /dev/null +++ b/src/openclaw_arena/data/historical_snapshots.csv @@ -0,0 +1,301 @@ +step,price,momentum,volatility +1,0.990175,-0.009825,0.004425 +2,0.981847,-0.008411,0.004368 +3,0.976997,-0.004940,0.009103 +4,0.969129,-0.008053,0.003578 +5,0.962191,-0.007159,0.002996 +6,0.956449,-0.005968,0.003855 +7,0.948191,-0.008634,0.006733 +8,0.943014,-0.005460,0.002384 +9,0.939278,-0.003961,0.003766 +10,0.937305,-0.002101,0.005762 +11,0.930617,-0.007136,0.007075 +12,0.926051,-0.004906,0.004007 +13,0.920446,-0.006052,0.006678 +14,0.920681,0.000255,0.005100 +15,0.920571,-0.000119,0.003297 +16,0.916174,-0.004776,0.006675 +17,0.919200,0.003302,0.006780 +18,0.922617,0.003718,0.006321 +19,0.924266,0.001787,0.002316 +20,0.926147,0.002036,0.002016 +21,0.925153,-0.001073,0.005325 +22,0.923620,-0.001658,0.007485 +23,0.926395,0.003005,0.001497 +24,0.926219,-0.000190,0.007323 +25,0.927525,0.001411,0.005769 +26,0.929733,0.002380,0.005287 +27,0.930886,0.001241,0.008391 +28,0.935789,0.005267,0.002713 +29,0.941063,0.005635,0.003253 +30,0.949914,0.009406,0.007324 +31,0.956920,0.007375,0.003149 +32,0.965437,0.008900,0.005019 +33,0.973480,0.008332,0.003336 +34,0.983399,0.010188,0.005765 +35,0.992351,0.009103,0.004358 +36,1.000513,0.008225,0.007017 +37,0.994491,-0.006019,0.010768 +38,0.989042,-0.005479,0.010519 +39,0.982943,-0.006167,0.008182 +40,0.976386,-0.006671,0.006160 +41,0.967349,-0.009256,0.005623 +42,0.958270,-0.009385,0.006565 +43,0.950265,-0.008354,0.005536 +44,0.941190,-0.009549,0.008289 +45,0.937966,-0.003425,0.005844 +46,0.932529,-0.005797,0.003355 +47,0.930970,-0.001672,0.006491 +48,0.926505,-0.004797,0.003099 +49,0.926811,0.000331,0.007563 +50,0.926812,0.000001,0.005835 +51,0.921049,-0.006218,0.007681 +52,0.917375,-0.003989,0.004615 +53,0.919367,0.002171,0.006024 +54,0.918635,-0.000796,0.000630 +55,0.922165,0.003843,0.006533 +56,0.921921,-0.000265,0.001617 +57,0.919796,-0.002305,0.006251 +58,0.922282,0.002702,0.002427 +59,0.926386,0.004450,0.004677 +60,0.927254,0.000936,0.004242 +61,0.927282,0.000030,0.006948 +62,0.929646,0.002550,0.003832 +63,0.937229,0.008157,0.008089 +64,0.943850,0.007064,0.005509 +65,0.946209,0.002499,0.007418 +66,0.950072,0.004082,0.005894 +67,0.953373,0.003475,0.008092 +68,0.956902,0.003701,0.008874 +69,0.966618,0.010154,0.007151 +70,0.972181,0.005755,0.007717 +71,0.981284,0.009363,0.004362 +72,0.990139,0.009024,0.004493 +73,0.997590,0.007525,0.008207 +74,0.989465,-0.008145,0.007154 +75,0.977199,-0.012397,0.008797 +76,0.969636,-0.007739,0.005510 +77,0.958581,-0.011401,0.008549 +78,0.950518,-0.008412,0.004189 +79,0.941469,-0.009519,0.006794 +80,0.933459,-0.008508,0.005797 +81,0.931271,-0.002344,0.008849 +82,0.928358,-0.003128,0.006349 +83,0.922262,-0.006567,0.004664 +84,0.921002,-0.001366,0.007012 +85,0.915289,-0.006203,0.005490 +86,0.911464,-0.004179,0.002771 +87,0.911516,0.000057,0.005931 +88,0.910525,-0.001088,0.002818 +89,0.906096,-0.004864,0.006102 +90,0.908636,0.002803,0.007099 +91,0.906047,-0.002850,0.004122 +92,0.904295,-0.001934,0.003287 +93,0.906770,0.002737,0.003931 +94,0.906537,-0.000257,0.002771 +95,0.906570,0.000037,0.003437 +96,0.905491,-0.001191,0.006691 +97,0.905037,-0.000501,0.006686 +98,0.908653,0.003995,0.002459 +99,0.910319,0.001833,0.005051 +100,0.915102,0.005255,0.003155 +101,0.918738,0.003974,0.003745 +102,0.923499,0.005181,0.002859 +103,0.932534,0.009784,0.008689 +104,0.938630,0.006536,0.002888 +105,0.945969,0.007819,0.003903 +106,0.956100,0.010710,0.008096 +107,0.961641,0.005796,0.007647 +108,0.967529,0.006122,0.008259 +109,0.979828,0.012712,0.009332 +110,0.992117,0.012542,0.008322 +111,0.980208,-0.012004,0.007407 +112,0.968518,-0.011926,0.007996 +113,0.961764,-0.006973,0.006812 +114,0.957138,-0.004810,0.009323 +115,0.947370,-0.010205,0.007237 +116,0.943939,-0.003621,0.009011 +117,0.940533,-0.003609,0.007864 +118,0.935564,-0.005283,0.003853 +119,0.929778,-0.006184,0.003290 +120,0.922183,-0.008169,0.007388 +121,0.914681,-0.008135,0.008052 +122,0.914510,-0.000187,0.007848 +123,0.909547,-0.005426,0.004891 +124,0.908509,-0.001141,0.003893 +125,0.904724,-0.004166,0.004194 +126,0.905559,0.000923,0.005069 +127,0.905252,-0.000339,0.001756 +128,0.903253,-0.002208,0.003032 +129,0.900908,-0.002597,0.004414 +130,0.902996,0.002318,0.003219 +131,0.900885,-0.002339,0.006309 +132,0.900429,-0.000506,0.004360 +133,0.902857,0.002697,0.001696 +134,0.907911,0.005597,0.005904 +135,0.911767,0.004248,0.002888 +136,0.913710,0.002131,0.004545 +137,0.920821,0.007783,0.007454 +138,0.923245,0.002632,0.006026 +139,0.924803,0.001688,0.008797 +140,0.928747,0.004265,0.005583 +141,0.934536,0.006232,0.003405 +142,0.937999,0.003706,0.008867 +143,0.942865,0.005188,0.007514 +144,0.949733,0.007284,0.005118 +145,0.958723,0.009466,0.004537 +146,0.964952,0.006497,0.008788 +147,0.973537,0.008897,0.005875 +148,0.966847,-0.006872,0.009317 +149,0.961432,-0.005600,0.010312 +150,0.954093,-0.007633,0.005690 +151,0.947602,-0.006804,0.005934 +152,0.940871,-0.007102,0.004259 +153,0.931369,-0.010099,0.007780 +154,0.921696,-0.010386,0.008990 +155,0.912509,-0.009968,0.009001 +156,0.910434,-0.002274,0.007801 +157,0.907345,-0.003392,0.004733 +158,0.906672,-0.000742,0.008071 +159,0.899673,-0.007719,0.008066 +160,0.897655,-0.002244,0.003185 +161,0.895033,-0.002920,0.001353 +162,0.894695,-0.000378,0.004024 +163,0.891908,-0.003115,0.003130 +164,0.894480,0.002884,0.007236 +165,0.890943,-0.003953,0.005999 +166,0.891272,0.000369,0.000627 +167,0.893457,0.002452,0.003445 +168,0.897310,0.004313,0.005887 +169,0.900508,0.003563,0.003891 +170,0.903976,0.003852,0.003659 +171,0.908608,0.005124,0.005100 +172,0.914653,0.006653,0.006977 +173,0.917126,0.002704,0.003571 +174,0.922561,0.005926,0.004296 +175,0.930132,0.008207,0.007451 +176,0.938061,0.008524,0.007269 +177,0.943171,0.005448,0.003571 +178,0.951651,0.008991,0.006618 +179,0.961292,0.010130,0.007832 +180,0.969328,0.008360,0.004101 +181,0.978375,0.009333,0.005033 +182,0.986151,0.007948,0.005156 +183,0.996117,0.010106,0.004902 +184,1.006945,0.010871,0.005481 +185,0.993494,-0.013358,0.009709 +186,0.985219,-0.008329,0.005674 +187,0.980350,-0.004942,0.010265 +188,0.975159,-0.005295,0.008499 +189,0.969355,-0.005952,0.006215 +190,0.961489,-0.008115,0.004406 +191,0.956887,-0.004786,0.005863 +192,0.951659,-0.005463,0.003545 +193,0.945919,-0.006031,0.003031 +194,0.943750,-0.002293,0.006602 +195,0.936413,-0.007774,0.007438 +196,0.934646,-0.001887,0.004958 +197,0.928014,-0.007095,0.007728 +198,0.926189,-0.001967,0.002489 +199,0.923989,-0.002375,0.001148 +200,0.920455,-0.003825,0.004336 +201,0.920893,0.000476,0.003142 +202,0.920361,-0.000578,0.000260 +203,0.921204,0.000916,0.001557 +204,0.924814,0.003919,0.005941 +205,0.924036,-0.000842,0.003765 +206,0.921963,-0.002243,0.007313 +207,0.922544,0.000630,0.003595 +208,0.926422,0.004203,0.003534 +209,0.927305,0.000954,0.005378 +210,0.928461,0.001246,0.006049 +211,0.935601,0.007690,0.007296 +212,0.941476,0.006280,0.004176 +213,0.946269,0.005091,0.003012 +214,0.955017,0.009245,0.007772 +215,0.960062,0.005282,0.005020 +216,0.968270,0.008549,0.005145 +217,0.973466,0.005366,0.007211 +218,0.981040,0.007780,0.004273 +219,0.992162,0.011337,0.007717 +220,1.004820,0.012758,0.009411 +221,1.017925,0.013042,0.009172 +222,1.006804,-0.010925,0.005572 +223,0.997965,-0.008780,0.004908 +224,0.987629,-0.010357,0.006051 +225,0.976524,-0.011244,0.008281 +226,0.968901,-0.007806,0.003159 +227,0.964517,-0.004525,0.007473 +228,0.960777,-0.003877,0.007409 +229,0.956529,-0.004421,0.005317 +230,0.954659,-0.001956,0.008342 +231,0.948181,-0.006786,0.005036 +232,0.941457,-0.007091,0.006278 +233,0.937424,-0.004284,0.002227 +234,0.932613,-0.005132,0.004391 +235,0.927889,-0.005065,0.005000 +236,0.925189,-0.002910,0.002059 +237,0.924578,-0.000661,0.002377 +238,0.923505,-0.001160,0.000528 +239,0.921628,-0.002033,0.002733 +240,0.924128,0.002713,0.004612 +241,0.928205,0.004412,0.006778 +242,0.928884,0.000731,0.001091 +243,0.927271,-0.001736,0.006451 +244,0.925856,-0.001526,0.007261 +245,0.931190,0.005760,0.006182 +246,0.930878,-0.000335,0.007569 +247,0.936052,0.005558,0.004393 +248,0.940741,0.005009,0.002738 +249,0.944007,0.003472,0.004597 +250,0.951453,0.007888,0.006187 +251,0.953607,0.002264,0.008984 +252,0.957187,0.003754,0.007619 +253,0.963754,0.006861,0.003503 +254,0.967585,0.003976,0.009575 +255,0.978200,0.010971,0.007817 +256,0.984841,0.006788,0.007127 +257,0.991312,0.006571,0.008662 +258,0.997633,0.006376,0.010162 +259,0.988687,-0.008967,0.005757 +260,0.978926,-0.009873,0.004506 +261,0.971243,-0.007849,0.005323 +262,0.964354,-0.007093,0.005442 +263,0.959224,-0.005319,0.007292 +264,0.955815,-0.003555,0.009124 +265,0.950853,-0.005191,0.005176 +266,0.942756,-0.008516,0.006533 +267,0.937331,-0.005754,0.002560 +268,0.930235,-0.007571,0.006370 +269,0.922459,-0.008358,0.008431 +270,0.918667,-0.004111,0.001934 +271,0.917179,-0.001620,0.004246 +272,0.912276,-0.005345,0.005475 +273,0.908588,-0.004043,0.003985 +274,0.905952,-0.002901,0.002765 +275,0.906375,0.000467,0.003128 +276,0.906020,-0.000392,0.000500 +277,0.906850,0.000916,0.001556 +278,0.909212,0.002605,0.003707 +279,0.909448,0.000259,0.001893 +280,0.913088,0.004002,0.004637 +281,0.918084,0.005472,0.006414 +282,0.917602,-0.000525,0.006725 +283,0.923837,0.006794,0.007217 +284,0.929073,0.005668,0.004580 +285,0.930452,0.001484,0.006811 +286,0.934758,0.004628,0.002632 +287,0.940890,0.006560,0.003930 +288,0.949726,0.009391,0.008020 +289,0.955121,0.005681,0.004342 +290,0.962545,0.007773,0.003825 +291,0.972952,0.010812,0.008270 +292,0.983370,0.010707,0.007369 +293,0.995606,0.012443,0.009597 +294,1.004720,0.009154,0.004271 +295,1.015984,0.011211,0.006058 +296,1.003425,-0.012361,0.008013 +297,0.995730,-0.007669,0.006796 +298,0.989415,-0.006342,0.007885 +299,0.982291,-0.007200,0.005259 +300,0.976361,-0.006036,0.006071 diff --git a/src/openclaw_arena/launch.py b/src/openclaw_arena/launch.py new file mode 100644 index 0000000..090d555 --- /dev/null +++ b/src/openclaw_arena/launch.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .models import LaunchProposal + + +@dataclass(slots=True) +class LaunchPolicyConfig: + max_launches_per_agent: int = 2 + min_confidence: float = 0.6 + max_initial_supply: int = 1_000_000 + cooldown_steps: int = 10 + + +class LaunchPolicyEngine: + def __init__(self, config: LaunchPolicyConfig) -> None: + self.config = config + + def validate( + self, + proposal: LaunchProposal, + launches_so_far: int, + last_launch_step: int | None, + ) -> tuple[bool, str]: + if launches_so_far >= self.config.max_launches_per_agent: + return False, "launch rejected: launch quota exceeded" + + if proposal.confidence < self.config.min_confidence: + return False, "launch rejected: confidence below minimum" + + if proposal.initial_supply <= 0 or proposal.initial_supply > self.config.max_initial_supply: + return False, "launch rejected: invalid initial supply" + + if not proposal.ticker.isalpha() or len(proposal.ticker) < 3 or len(proposal.ticker) > 8: + return False, "launch rejected: invalid ticker format" + + if last_launch_step is not None and (proposal.step - last_launch_step) < self.config.cooldown_steps: + return False, "launch rejected: cooldown active" + + return True, "launch approved" diff --git a/src/openclaw_arena/main.py b/src/openclaw_arena/main.py new file mode 100644 index 0000000..ad38ab1 --- /dev/null +++ b/src/openclaw_arena/main.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +import json + +from .simulation import run_simulation + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run OpenClaw Arena v0 simulation") + parser.add_argument("--steps", type=int, default=100, help="Number of simulation steps") + parser.add_argument("--seed", type=int, default=1, help="Random seed") + parser.add_argument( + "--snapshot-path", + type=str, + default=None, + help="Optional CSV snapshot file path (columns: step,price,momentum,volatility)", + ) + parser.add_argument("--json", action="store_true", help="Print full JSON output") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + result = run_simulation(steps=args.steps, seed=args.seed, snapshot_path=args.snapshot_path) + + if args.json: + print(json.dumps(result, indent=2)) + return + + print( + "OpenClaw Arena v0 " + f"| steps={result['steps']} seed={result['seed']} final_price={result['final_price']} " + f"source={result['snapshot_source']} markets={len(result['token_markets'])}" + ) + print("\nScoreboard") + for row in result["scoreboard"]: + print( + f"- {row['agent']}: pnl={row['pnl']:.4f} final_equity={row['final_equity']:.4f} " + f"max_drawdown={row['max_drawdown']:.4f} launches={row['launches_approved']} " + f"tokens={row['token_inventory']}" + ) + + +if __name__ == "__main__": + main() diff --git a/src/openclaw_arena/market_data.py b/src/openclaw_arena/market_data.py new file mode 100644 index 0000000..d51cdeb --- /dev/null +++ b/src/openclaw_arena/market_data.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import csv +from importlib import resources +from pathlib import Path + +from .models import MarketState + + +def load_market_snapshots(steps: int, snapshot_path: str | None = None) -> list[MarketState]: + if steps <= 0: + return [] + + if snapshot_path is None: + with resources.files("openclaw_arena").joinpath("data/historical_snapshots.csv").open("r", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + else: + with Path(snapshot_path).open("r", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + + if not rows: + raise ValueError("No market snapshots found") + + selected = rows[:steps] + snapshots: list[MarketState] = [] + for idx, row in enumerate(selected, start=1): + snapshots.append( + MarketState( + step=idx, + price=float(row["price"]), + momentum=float(row["momentum"]), + volatility=float(row["volatility"]), + ) + ) + return snapshots diff --git a/src/openclaw_arena/models.py b/src/openclaw_arena/models.py new file mode 100644 index 0000000..df4d567 --- /dev/null +++ b/src/openclaw_arena/models.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + + +class Side(str, Enum): + BUY = "buy" + SELL = "sell" + HOLD = "hold" + + +@dataclass(slots=True) +class MarketState: + step: int + price: float + momentum: float + volatility: float + + +@dataclass(slots=True) +class AgentAction: + agent_id: str + side: Side + quantity: float + confidence: float + rationale: str + + +@dataclass(slots=True) +class Portfolio: + cash: float + position: float = 0.0 + token_inventory: dict[str, float] = field(default_factory=dict) + equity_curve: list[float] = field(default_factory=list) + + +@dataclass(slots=True) +class FillResult: + accepted: bool + reason: str + filled_qty: float = 0.0 + notional: float = 0.0 + + +@dataclass(slots=True) +class LaunchProposal: + agent_id: str + step: int + ticker: str + thesis: str + initial_supply: int + confidence: float + + +@dataclass(slots=True) +class TokenOrder: + agent_id: str + ticker: str + side: Side + quantity: float + + +@dataclass(slots=True) +class TokenFillResult: + accepted: bool + reason: str + filled_base_qty: float = 0.0 + quote_delta: float = 0.0 + execution_price: float = 0.0 diff --git a/src/openclaw_arena/risk.py b/src/openclaw_arena/risk.py new file mode 100644 index 0000000..0a8afad --- /dev/null +++ b/src/openclaw_arena/risk.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .models import AgentAction, FillResult, Portfolio, Side + + +@dataclass(slots=True) +class RiskConfig: + max_position: float = 100.0 + max_trade_notional: float = 500.0 + min_cash_reserve: float = 50.0 + + +class RiskEngine: + def __init__(self, config: RiskConfig) -> None: + self.config = config + + def validate(self, action: AgentAction, portfolio: Portfolio, price: float) -> FillResult: + if action.side is Side.HOLD or action.quantity <= 0: + return FillResult(True, "hold", 0.0, 0.0) + + requested_notional = action.quantity * price + if requested_notional > self.config.max_trade_notional: + max_qty = self.config.max_trade_notional / price + else: + max_qty = action.quantity + + if action.side is Side.BUY: + max_cash_qty = max(0.0, (portfolio.cash - self.config.min_cash_reserve) / price) + allowed_qty = min(max_qty, max_cash_qty, self.config.max_position - portfolio.position) + if allowed_qty <= 0: + return FillResult(False, "buy rejected by cash/position limits") + return FillResult(True, "buy allowed", allowed_qty, allowed_qty * price) + + # SELL path: no shorting + allowed_qty = min(max_qty, portfolio.position) + if allowed_qty <= 0: + return FillResult(False, "sell rejected (no inventory)") + return FillResult(True, "sell allowed", allowed_qty, allowed_qty * price) diff --git a/src/openclaw_arena/simulation.py b/src/openclaw_arena/simulation.py new file mode 100644 index 0000000..749258b --- /dev/null +++ b/src/openclaw_arena/simulation.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .agents import BaseAgent, MeanReversionAgent, MomentumAgent, NoiseSentimentAgent +from .launch import LaunchPolicyConfig, LaunchPolicyEngine +from .market_data import load_market_snapshots +from .models import AgentAction, Portfolio, Side +from .risk import RiskConfig, RiskEngine +from .venue import TokenVenue + + +@dataclass(slots=True) +class AgentContext: + agent: BaseAgent + portfolio: Portfolio + + +def _apply_fill(action: AgentAction, portfolio: Portfolio, price: float, qty: float) -> None: + if qty <= 0: + return + notional = qty * price + if action.side is Side.BUY: + portfolio.cash -= notional + portfolio.position += qty + elif action.side is Side.SELL: + portfolio.cash += notional + portfolio.position -= qty + + +def _equity(portfolio: Portfolio, price: float, token_value: float) -> float: + return portfolio.cash + portfolio.position * price + token_value + + +def _max_drawdown(equity_curve: list[float]) -> float: + if not equity_curve: + return 0.0 + peak = equity_curve[0] + max_dd = 0.0 + for value in equity_curve: + peak = max(peak, value) + dd = (peak - value) / peak if peak else 0.0 + max_dd = max(max_dd, dd) + return max_dd + + +def run_simulation(steps: int = 100, seed: int = 1, snapshot_path: str | None = None) -> dict: + snapshots = load_market_snapshots(steps=steps, snapshot_path=snapshot_path) + if not snapshots: + raise ValueError("No snapshots available for simulation") + + start_price = snapshots[0].price + risk = RiskEngine(RiskConfig()) + launch_policy = LaunchPolicyEngine(LaunchPolicyConfig()) + venue = TokenVenue() + + agents: list[AgentContext] = [ + AgentContext(MomentumAgent("momentum-1", aggressiveness=1.2), Portfolio(cash=1000.0)), + AgentContext(MeanReversionAgent("reverter-1", anchor_price=start_price), Portfolio(cash=1000.0)), + AgentContext(NoiseSentimentAgent("sentiment-1", seed=seed + 42), Portfolio(cash=1000.0)), + ] + + logs: list[dict] = [] + launch_logs: list[dict] = [] + token_trade_logs: list[dict] = [] + launch_count: dict[str, int] = {ctx.agent.agent_id: 0 for ctx in agents} + last_launch_step: dict[str, int | None] = {ctx.agent.agent_id: None for ctx in agents} + price = start_price + + for state in snapshots: + step = state.step + price = state.price + + for ctx in agents: + proposal = ctx.agent.propose_launch(state) + if proposal is not None: + approved, status = launch_policy.validate( + proposal, + launches_so_far=launch_count[ctx.agent.agent_id], + last_launch_step=last_launch_step[ctx.agent.agent_id], + ) + if approved: + ok, launch_status = venue.launch_token( + ticker=proposal.ticker, + creator=proposal.agent_id, + initial_supply=proposal.initial_supply, + seed_price=max(0.0001, state.price * (1 + state.momentum)), + ) + status = launch_status if ok else launch_status + if ok: + launch_count[ctx.agent.agent_id] += 1 + last_launch_step[ctx.agent.agent_id] = step + launch_logs.append( + { + "step": step, + "agent": proposal.agent_id, + "ticker": proposal.ticker, + "confidence": round(proposal.confidence, 4), + "initial_supply": proposal.initial_supply, + "status": status, + } + ) + + # Venue-level token trading across all launched tokens by all agents. + for ticker, market in venue.markets.items(): + for ctx in agents: + inventory = ctx.portfolio.token_inventory.get(ticker, 0.0) + order = ctx.agent.propose_token_order(state, ticker, market.price, inventory) + if order is None: + continue + fill = venue.process_order(order, ctx.portfolio) + token_trade_logs.append( + { + "step": step, + "agent": ctx.agent.agent_id, + "ticker": ticker, + "side": order.side.value, + "requested_qty": round(order.quantity, 4), + "filled_base_qty": round(fill.filled_base_qty, 6), + "quote_delta": round(fill.quote_delta, 6), + "execution_price": round(fill.execution_price, 8), + "status": fill.reason, + "inventory_after": round(ctx.portfolio.token_inventory.get(ticker, 0.0), 6), + } + ) + + for ctx in agents: + action = ctx.agent.act(state) + fill = risk.validate(action, ctx.portfolio, state.price) + if fill.accepted: + _apply_fill(action, ctx.portfolio, state.price, fill.filled_qty) + token_value = venue.mark_to_market_value(ctx.portfolio) + equity = _equity(ctx.portfolio, state.price, token_value) + ctx.portfolio.equity_curve.append(equity) + logs.append( + { + "step": step, + "agent": action.agent_id, + "price": round(state.price, 6), + "momentum": round(state.momentum, 6), + "side": action.side.value, + "requested_qty": round(action.quantity, 4), + "filled_qty": round(fill.filled_qty, 4), + "status": fill.reason, + "cash": round(ctx.portfolio.cash, 4), + "position": round(ctx.portfolio.position, 4), + "token_value": round(token_value, 4), + "equity": round(equity, 4), + } + ) + + scoreboard = [] + for ctx in agents: + token_value = venue.mark_to_market_value(ctx.portfolio) + final_equity = _equity(ctx.portfolio, price, token_value) + pnl = final_equity - 1000.0 + scoreboard.append( + { + "agent": ctx.agent.agent_id, + "final_equity": round(final_equity, 4), + "pnl": round(pnl, 4), + "max_drawdown": round(_max_drawdown(ctx.portfolio.equity_curve), 4), + "launches_approved": launch_count[ctx.agent.agent_id], + "token_inventory": {k: round(v, 6) for k, v in sorted(ctx.portfolio.token_inventory.items()) if abs(v) > 1e-12}, + } + ) + + scoreboard.sort(key=lambda row: row["pnl"], reverse=True) + token_markets = { + ticker: { + "creator": market.creator, + "price": round(market.price, 8), + "base_reserve": round(market.base_reserve, 6), + "quote_reserve": round(market.quote_reserve, 6), + } + for ticker, market in sorted(venue.markets.items()) + } + return { + "steps": len(snapshots), + "seed": seed, + "snapshot_source": snapshot_path or "package:data/historical_snapshots.csv", + "final_price": round(price, 6), + "scoreboard": scoreboard, + "token_markets": token_markets, + "logs": logs, + "launch_logs": launch_logs, + "token_trade_logs": token_trade_logs, + } diff --git a/src/openclaw_arena/venue.py b/src/openclaw_arena/venue.py new file mode 100644 index 0000000..00ca1ac --- /dev/null +++ b/src/openclaw_arena/venue.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .models import Portfolio, Side, TokenFillResult, TokenOrder + + +@dataclass(slots=True) +class TokenMarket: + ticker: str + creator: str + base_reserve: float + quote_reserve: float + + @property + def price(self) -> float: + if self.base_reserve <= 0: + return 0.0 + return self.quote_reserve / self.base_reserve + + +class TokenVenue: + def __init__(self) -> None: + self.markets: dict[str, TokenMarket] = {} + + def launch_token(self, ticker: str, creator: str, initial_supply: int, seed_price: float) -> tuple[bool, str]: + if ticker in self.markets: + return False, "launch rejected: ticker already exists" + if initial_supply <= 0 or seed_price <= 0: + return False, "launch rejected: invalid supply/price" + + self.markets[ticker] = TokenMarket( + ticker=ticker, + creator=creator, + base_reserve=float(initial_supply), + quote_reserve=float(initial_supply) * seed_price, + ) + return True, "launch market created" + + def process_order(self, order: TokenOrder, portfolio: Portfolio) -> TokenFillResult: + market = self.markets.get(order.ticker) + if market is None: + return TokenFillResult(False, "token market missing") + + if order.quantity <= 0 or order.side is Side.HOLD: + return TokenFillResult(True, "hold", 0.0, 0.0, market.price) + + k = market.base_reserve * market.quote_reserve + if k <= 0: + return TokenFillResult(False, "invalid market reserves") + + if order.side is Side.BUY: + spend_quote = min(portfolio.cash, order.quantity * market.price) + if spend_quote <= 0: + return TokenFillResult(False, "buy rejected: no cash") + new_quote = market.quote_reserve + spend_quote + new_base = k / new_quote + base_out = max(0.0, market.base_reserve - new_base) + if base_out <= 0: + return TokenFillResult(False, "buy rejected: no output") + + market.quote_reserve = new_quote + market.base_reserve = new_base + portfolio.cash -= spend_quote + portfolio.token_inventory[order.ticker] = portfolio.token_inventory.get(order.ticker, 0.0) + base_out + exec_price = spend_quote / base_out if base_out else market.price + return TokenFillResult(True, "buy executed", base_out, -spend_quote, exec_price) + + # SELL path + inventory = portfolio.token_inventory.get(order.ticker, 0.0) + sell_qty = min(order.quantity, inventory) + if sell_qty <= 0: + return TokenFillResult(False, "sell rejected: no inventory") + + new_base = market.base_reserve + sell_qty + new_quote = k / new_base + quote_out = max(0.0, market.quote_reserve - new_quote) + if quote_out <= 0: + return TokenFillResult(False, "sell rejected: no output") + + market.base_reserve = new_base + market.quote_reserve = new_quote + portfolio.cash += quote_out + portfolio.token_inventory[order.ticker] = inventory - sell_qty + exec_price = quote_out / sell_qty if sell_qty else market.price + return TokenFillResult(True, "sell executed", -sell_qty, quote_out, exec_price) + + def mark_to_market_value(self, portfolio: Portfolio) -> float: + value = 0.0 + for ticker, qty in portfolio.token_inventory.items(): + market = self.markets.get(ticker) + if market is None: + continue + value += qty * market.price + return value diff --git a/tests/test_launch.py b/tests/test_launch.py new file mode 100644 index 0000000..e3a9833 --- /dev/null +++ b/tests/test_launch.py @@ -0,0 +1,36 @@ +from openclaw_arena.launch import LaunchPolicyConfig, LaunchPolicyEngine +from openclaw_arena.models import LaunchProposal + + +def test_launch_policy_rejects_quota_exceeded() -> None: + engine = LaunchPolicyEngine(LaunchPolicyConfig(max_launches_per_agent=1)) + proposal = LaunchProposal( + agent_id="a1", + step=20, + ticker="META", + thesis="test", + initial_supply=100_000, + confidence=0.9, + ) + + approved, reason = engine.validate(proposal, launches_so_far=1, last_launch_step=5) + + assert not approved + assert "quota" in reason + + +def test_launch_policy_rejects_cooldown() -> None: + engine = LaunchPolicyEngine(LaunchPolicyConfig(cooldown_steps=10)) + proposal = LaunchProposal( + agent_id="a1", + step=12, + ticker="META", + thesis="test", + initial_supply=100_000, + confidence=0.9, + ) + + approved, reason = engine.validate(proposal, launches_so_far=0, last_launch_step=8) + + assert not approved + assert "cooldown" in reason diff --git a/tests/test_market_data.py b/tests/test_market_data.py new file mode 100644 index 0000000..998857a --- /dev/null +++ b/tests/test_market_data.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from openclaw_arena.market_data import load_market_snapshots + + +def test_load_market_snapshots_from_package_data() -> None: + snapshots = load_market_snapshots(steps=5) + + assert len(snapshots) == 5 + assert snapshots[0].step == 1 + assert snapshots[0].price > 0 + + +def test_load_market_snapshots_from_custom_file(tmp_path: Path) -> None: + custom = tmp_path / "snapshots.csv" + custom.write_text( + "step,price,momentum,volatility\n" + "10,1.01,0.01,0.02\n" + "11,1.00,-0.01,0.03\n", + encoding="utf-8", + ) + + snapshots = load_market_snapshots(steps=10, snapshot_path=str(custom)) + + assert len(snapshots) == 2 + assert snapshots[0].step == 1 + assert snapshots[1].momentum == -0.01 diff --git a/tests/test_risk.py b/tests/test_risk.py new file mode 100644 index 0000000..b7d1f39 --- /dev/null +++ b/tests/test_risk.py @@ -0,0 +1,23 @@ +from openclaw_arena.models import AgentAction, Portfolio, Side +from openclaw_arena.risk import RiskConfig, RiskEngine + + +def test_buy_respects_cash_and_reserve() -> None: + engine = RiskEngine(RiskConfig(max_position=100, max_trade_notional=500, min_cash_reserve=50)) + portfolio = Portfolio(cash=100, position=0) + action = AgentAction(agent_id="a", side=Side.BUY, quantity=100, confidence=1.0, rationale="test") + + result = engine.validate(action, portfolio, price=10) + + assert result.accepted + assert result.filled_qty == 5 # (100 - 50) / 10 + + +def test_sell_rejected_without_inventory() -> None: + engine = RiskEngine(RiskConfig()) + portfolio = Portfolio(cash=1000, position=0) + action = AgentAction(agent_id="a", side=Side.SELL, quantity=1, confidence=1.0, rationale="test") + + result = engine.validate(action, portfolio, price=1) + + assert not result.accepted diff --git a/tests/test_simulation.py b/tests/test_simulation.py new file mode 100644 index 0000000..b06a654 --- /dev/null +++ b/tests/test_simulation.py @@ -0,0 +1,36 @@ +from openclaw_arena.simulation import run_simulation + + +def test_simulation_produces_scoreboard() -> None: + result = run_simulation(steps=10, seed=2) + + assert result["steps"] == 10 + assert result["snapshot_source"] == "package:data/historical_snapshots.csv" + assert len(result["scoreboard"]) == 3 + assert all("agent" in row and "pnl" in row for row in result["scoreboard"]) + assert all("launches_approved" in row for row in result["scoreboard"]) + assert all("token_inventory" in row for row in result["scoreboard"]) + assert len(result["logs"]) == 30 # 3 agents x 10 steps + + +def test_simulation_contains_launch_and_token_trade_logs() -> None: + result = run_simulation(steps=20, seed=7) + + assert "launch_logs" in result + assert "token_trade_logs" in result + assert isinstance(result["launch_logs"], list) + assert isinstance(result["token_trade_logs"], list) + + +def test_simulation_caps_to_available_snapshots() -> None: + result = run_simulation(steps=1000, seed=7) + + assert result["steps"] == 300 + assert len(result["logs"]) == 900 + + +def test_simulation_has_token_market_state() -> None: + result = run_simulation(steps=60, seed=7) + + assert "token_markets" in result + assert isinstance(result["token_markets"], dict) diff --git a/tests/test_venue.py b/tests/test_venue.py new file mode 100644 index 0000000..661ca67 --- /dev/null +++ b/tests/test_venue.py @@ -0,0 +1,32 @@ +from openclaw_arena.models import Portfolio, Side, TokenOrder +from openclaw_arena.venue import TokenVenue + + +def test_venue_launch_and_price_moves_on_buy() -> None: + venue = TokenVenue() + ok, _ = venue.launch_token("META", creator="a1", initial_supply=1000, seed_price=1.0) + assert ok + + portfolio = Portfolio(cash=200) + before = venue.markets["META"].price + fill = venue.process_order(TokenOrder(agent_id="a1", ticker="META", side=Side.BUY, quantity=100), portfolio) + after = venue.markets["META"].price + + assert fill.accepted + assert fill.filled_base_qty > 0 + assert after > before + assert portfolio.token_inventory["META"] > 0 + + +def test_cross_agent_inventory_independent() -> None: + venue = TokenVenue() + ok, _ = venue.launch_token("MOMO", creator="a1", initial_supply=500, seed_price=1.0) + assert ok + + p1 = Portfolio(cash=100) + p2 = Portfolio(cash=100) + + venue.process_order(TokenOrder(agent_id="a1", ticker="MOMO", side=Side.BUY, quantity=50), p1) + venue.process_order(TokenOrder(agent_id="a2", ticker="MOMO", side=Side.BUY, quantity=30), p2) + + assert p1.token_inventory.get("MOMO", 0) != p2.token_inventory.get("MOMO", 0)