o2-cli 0.1.0__py3-none-any.whl
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.
- o2_cli/__init__.py +30 -0
- o2_cli/__main__.py +6 -0
- o2_cli/cli.py +94 -0
- o2_cli/client.py +307 -0
- o2_cli/commands/__init__.py +1 -0
- o2_cli/commands/_helpers.py +65 -0
- o2_cli/commands/account.py +46 -0
- o2_cli/commands/admin.py +147 -0
- o2_cli/commands/auth.py +140 -0
- o2_cli/commands/balance.py +64 -0
- o2_cli/commands/deposits.py +89 -0
- o2_cli/commands/fees.py +73 -0
- o2_cli/commands/markets.py +129 -0
- o2_cli/commands/mm.py +182 -0
- o2_cli/commands/notifications.py +136 -0
- o2_cli/commands/orders.py +331 -0
- o2_cli/commands/positions.py +158 -0
- o2_cli/commands/settings.py +129 -0
- o2_cli/commands/setup_cmd.py +78 -0
- o2_cli/commands/trades.py +86 -0
- o2_cli/commands/withdrawals.py +175 -0
- o2_cli/config.py +87 -0
- o2_cli/exceptions.py +31 -0
- o2_cli/output.py +224 -0
- o2_cli/setup.py +561 -0
- o2_cli-0.1.0.dist-info/METADATA +141 -0
- o2_cli-0.1.0.dist-info/RECORD +31 -0
- o2_cli-0.1.0.dist-info/WHEEL +5 -0
- o2_cli-0.1.0.dist-info/entry_points.txt +2 -0
- o2_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- o2_cli-0.1.0.dist-info/top_level.txt +1 -0
o2_cli/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""YAML configuration management for O2 CLI.
|
|
2
|
+
|
|
3
|
+
Config file: ~/.o2/config.yaml
|
|
4
|
+
Supports named profiles (default, production, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field, asdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CONFIG_DIR = Path.home() / ".o2"
|
|
15
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Profile:
|
|
20
|
+
"""A named configuration profile."""
|
|
21
|
+
|
|
22
|
+
api_url: str = "http://localhost:8000/api/v1"
|
|
23
|
+
timeout: float = 30.0
|
|
24
|
+
auth_type: str = "jwt" # "jwt" or "api_key"
|
|
25
|
+
token: Optional[str] = None
|
|
26
|
+
api_key_id: Optional[str] = None
|
|
27
|
+
api_secret: Optional[str] = None
|
|
28
|
+
default_market_id: Optional[int] = None
|
|
29
|
+
default_chain: str = "base"
|
|
30
|
+
output_format: str = "auto" # "auto", "json", "table", "text"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AppConfig:
|
|
35
|
+
"""Root config with named profiles."""
|
|
36
|
+
|
|
37
|
+
active_profile: str = "default"
|
|
38
|
+
profiles: dict[str, Profile] = field(default_factory=lambda: {"default": Profile()})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_config(config_path: Path = CONFIG_FILE) -> AppConfig:
|
|
42
|
+
"""Load config from YAML file. Returns default config if file doesn't exist."""
|
|
43
|
+
if not config_path.exists():
|
|
44
|
+
return AppConfig()
|
|
45
|
+
|
|
46
|
+
with open(config_path) as f:
|
|
47
|
+
data = yaml.safe_load(f) or {}
|
|
48
|
+
|
|
49
|
+
profiles = {}
|
|
50
|
+
for name, pdata in data.get("profiles", {}).items():
|
|
51
|
+
profiles[name] = Profile(**pdata) if isinstance(pdata, dict) else Profile()
|
|
52
|
+
|
|
53
|
+
if not profiles:
|
|
54
|
+
profiles["default"] = Profile()
|
|
55
|
+
|
|
56
|
+
return AppConfig(
|
|
57
|
+
active_profile=data.get("active_profile", "default"),
|
|
58
|
+
profiles=profiles,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_config(config: AppConfig, config_path: Path = CONFIG_FILE) -> None:
|
|
63
|
+
"""Save config to YAML file."""
|
|
64
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
data = {
|
|
67
|
+
"active_profile": config.active_profile,
|
|
68
|
+
"profiles": {name: asdict(p) for name, p in config.profiles.items()},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
with open(config_path, "w") as f:
|
|
72
|
+
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_active_profile(config: AppConfig) -> Profile:
|
|
76
|
+
"""Get the currently active profile."""
|
|
77
|
+
return config.profiles.get(config.active_profile, Profile())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save_token(profile_name: str, token: str, config_path: Path = CONFIG_FILE) -> None:
|
|
81
|
+
"""Persist JWT token to config file for a profile."""
|
|
82
|
+
config = load_config(config_path)
|
|
83
|
+
if profile_name not in config.profiles:
|
|
84
|
+
config.profiles[profile_name] = Profile()
|
|
85
|
+
config.profiles[profile_name].token = token
|
|
86
|
+
config.profiles[profile_name].auth_type = "jwt"
|
|
87
|
+
save_config(config, config_path)
|
o2_cli/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Custom exception hierarchy for O2 CLI."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class O2CLIError(Exception):
|
|
5
|
+
"""Base exception for O2 CLI."""
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthenticationError(O2CLIError):
|
|
10
|
+
"""Auth failure (no token, expired, invalid)."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(O2CLIError):
|
|
15
|
+
"""Backend returned non-success response."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status_code: int, detail: str, code: str | None = None):
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.detail = detail
|
|
20
|
+
self.code = code
|
|
21
|
+
super().__init__(f"[{status_code}] {detail}" + (f" (code={code})" if code else ""))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(O2CLIError):
|
|
25
|
+
"""Configuration file error."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConnectionError(O2CLIError):
|
|
30
|
+
"""Cannot reach backend."""
|
|
31
|
+
pass
|
o2_cli/output.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Output formatting with TTY detection.
|
|
2
|
+
|
|
3
|
+
- TTY: Rich colored tables
|
|
4
|
+
- --json: compact JSON to stdout
|
|
5
|
+
- Pipe (no TTY): plain text columns
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_tty() -> bool:
|
|
17
|
+
return sys.stdout.isatty()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OutputFormatter:
|
|
21
|
+
"""Handles all output formatting with TTY detection."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, json_mode: bool = False):
|
|
24
|
+
self.json_mode = json_mode
|
|
25
|
+
if json_mode:
|
|
26
|
+
self._console = Console(file=sys.stderr) # JSON goes to stdout, errors to stderr
|
|
27
|
+
elif is_tty():
|
|
28
|
+
self._console = Console()
|
|
29
|
+
else:
|
|
30
|
+
self._console = Console(force_terminal=False, no_color=True)
|
|
31
|
+
|
|
32
|
+
def print_json(self, data: Any) -> None:
|
|
33
|
+
"""Print data as JSON to stdout."""
|
|
34
|
+
print(json.dumps(data, indent=2, default=str))
|
|
35
|
+
|
|
36
|
+
def print_error(self, message: str, code: str | None = None) -> None:
|
|
37
|
+
"""Print error message."""
|
|
38
|
+
if self.json_mode:
|
|
39
|
+
error_data = {"error": message}
|
|
40
|
+
if code:
|
|
41
|
+
error_data["code"] = code
|
|
42
|
+
print(json.dumps(error_data), file=sys.stderr)
|
|
43
|
+
else:
|
|
44
|
+
self._console.print(f"[bold red]Error:[/bold red] {message}")
|
|
45
|
+
|
|
46
|
+
def print_success(self, message: str) -> None:
|
|
47
|
+
"""Print success message."""
|
|
48
|
+
if not self.json_mode:
|
|
49
|
+
self._console.print(f"[bold green]✓[/bold green] {message}")
|
|
50
|
+
|
|
51
|
+
def print_balance(self, data: dict) -> None:
|
|
52
|
+
"""Format and print balance info."""
|
|
53
|
+
if self.json_mode:
|
|
54
|
+
self.print_json(data)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Dual balance system: cash + bonus
|
|
58
|
+
cash = data.get("cash", data.get("data", {}))
|
|
59
|
+
bonus = data.get("bonus", {})
|
|
60
|
+
summary = data.get("summary", {})
|
|
61
|
+
|
|
62
|
+
if summary:
|
|
63
|
+
self._console.print("\n[bold]Account Summary[/bold]")
|
|
64
|
+
self._console.print(f" Total: ${summary.get('total', '0')}")
|
|
65
|
+
self._console.print(f" Withdrawable: ${summary.get('withdrawable', '0')}")
|
|
66
|
+
self._console.print(f" Trading: ${summary.get('trading', '0')}")
|
|
67
|
+
|
|
68
|
+
if cash:
|
|
69
|
+
self._console.print("\n[bold cyan]Cash Account[/bold cyan]")
|
|
70
|
+
self._console.print(f" Available: ${cash.get('available', cash.get('available_balance', '0'))}")
|
|
71
|
+
self._console.print(f" Frozen: ${cash.get('frozen', cash.get('frozen_balance', '0'))}")
|
|
72
|
+
|
|
73
|
+
if bonus:
|
|
74
|
+
self._console.print("\n[bold magenta]Bonus Account[/bold magenta]")
|
|
75
|
+
self._console.print(f" Available: ${bonus.get('available', '0')}")
|
|
76
|
+
self._console.print(f" Frozen: ${bonus.get('frozen', '0')}")
|
|
77
|
+
|
|
78
|
+
if not summary and not cash and not bonus:
|
|
79
|
+
# Fallback: just print what we got
|
|
80
|
+
self._console.print(data)
|
|
81
|
+
|
|
82
|
+
def print_orders(self, data: dict) -> None:
|
|
83
|
+
"""Format and print orders."""
|
|
84
|
+
if self.json_mode:
|
|
85
|
+
self.print_json(data)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
orders = data.get("orders", data if isinstance(data, list) else [])
|
|
89
|
+
total = data.get("total", len(orders) if isinstance(orders, list) else 0)
|
|
90
|
+
|
|
91
|
+
if not orders:
|
|
92
|
+
self._console.print("[dim]No orders found.[/dim]")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
table = Table(title=f"Orders ({total})")
|
|
96
|
+
table.add_column("Order ID", style="dim", max_width=12)
|
|
97
|
+
table.add_column("Market", justify="right")
|
|
98
|
+
table.add_column("Side", style="bold")
|
|
99
|
+
table.add_column("Type")
|
|
100
|
+
table.add_column("Amount")
|
|
101
|
+
table.add_column("Price")
|
|
102
|
+
table.add_column("Status")
|
|
103
|
+
table.add_column("Time")
|
|
104
|
+
|
|
105
|
+
for order in orders:
|
|
106
|
+
side = str(order.get("side", ""))
|
|
107
|
+
side_style = "green" if side == "long" else "red" if side == "short" else ""
|
|
108
|
+
status = str(order.get("status", ""))
|
|
109
|
+
status_style = "yellow" if status == "open" else "green" if status == "filled" else "dim"
|
|
110
|
+
|
|
111
|
+
table.add_row(
|
|
112
|
+
str(order.get("order_id", ""))[:12],
|
|
113
|
+
str(order.get("market_id", "")),
|
|
114
|
+
f"[{side_style}]{side}[/{side_style}]",
|
|
115
|
+
str(order.get("order_type", "")),
|
|
116
|
+
str(order.get("base_amount", order.get("base_units", ""))),
|
|
117
|
+
str(order.get("price", "-") or "-"),
|
|
118
|
+
f"[{status_style}]{status}[/{status_style}]",
|
|
119
|
+
str(order.get("created_at", ""))[:19],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self._console.print(table)
|
|
123
|
+
|
|
124
|
+
def print_positions(self, data: dict) -> None:
|
|
125
|
+
"""Format and print positions."""
|
|
126
|
+
if self.json_mode:
|
|
127
|
+
self.print_json(data)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
positions = data.get("positions", data if isinstance(data, list) else [])
|
|
131
|
+
|
|
132
|
+
if not positions:
|
|
133
|
+
self._console.print("[dim]No open positions.[/dim]")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
table = Table(title="Positions")
|
|
137
|
+
table.add_column("Market", justify="right")
|
|
138
|
+
table.add_column("Side", style="bold")
|
|
139
|
+
table.add_column("Size")
|
|
140
|
+
table.add_column("Entry")
|
|
141
|
+
table.add_column("Mark")
|
|
142
|
+
table.add_column("PnL")
|
|
143
|
+
table.add_column("Leverage")
|
|
144
|
+
|
|
145
|
+
for pos in positions:
|
|
146
|
+
side = str(pos.get("side", ""))
|
|
147
|
+
side_style = "green" if side == "long" else "red"
|
|
148
|
+
pnl = pos.get("unrealized_pnl", "0")
|
|
149
|
+
pnl_style = "green" if str(pnl).startswith("-") is False and str(pnl) != "0" else "red"
|
|
150
|
+
|
|
151
|
+
table.add_row(
|
|
152
|
+
str(pos.get("market_id", "")),
|
|
153
|
+
f"[{side_style}]{side}[/{side_style}]",
|
|
154
|
+
str(pos.get("base_units", "")),
|
|
155
|
+
str(pos.get("entry_price", "")),
|
|
156
|
+
str(pos.get("mark_price", "-") or "-"),
|
|
157
|
+
f"[{pnl_style}]{pnl}[/{pnl_style}]",
|
|
158
|
+
str(pos.get("leverage", "-") or "-"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self._console.print(table)
|
|
162
|
+
|
|
163
|
+
def print_markets(self, data: dict) -> None:
|
|
164
|
+
"""Format and print markets list."""
|
|
165
|
+
if self.json_mode:
|
|
166
|
+
self.print_json(data)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
markets = data.get("markets", data if isinstance(data, list) else [])
|
|
170
|
+
|
|
171
|
+
if not markets:
|
|
172
|
+
self._console.print("[dim]No markets found.[/dim]")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
table = Table(title="Markets")
|
|
176
|
+
table.add_column("ID", justify="right", style="bold")
|
|
177
|
+
table.add_column("Symbol")
|
|
178
|
+
table.add_column("Mark Price", justify="right")
|
|
179
|
+
table.add_column("Status")
|
|
180
|
+
|
|
181
|
+
for m in markets:
|
|
182
|
+
table.add_row(
|
|
183
|
+
str(m.get("market_id", m.get("marketId", m.get("id", "")))),
|
|
184
|
+
str(m.get("symbol", m.get("name", m.get("base_symbol", m.get("baseToken", ""))))),
|
|
185
|
+
str(m.get("mark_price", m.get("price", "-")) or "-"),
|
|
186
|
+
"active" if m.get("isActive", m.get("is_active", True)) else "inactive",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
self._console.print(table)
|
|
190
|
+
|
|
191
|
+
def print_orderbook(self, data: dict) -> None:
|
|
192
|
+
"""Format and print order book."""
|
|
193
|
+
if self.json_mode:
|
|
194
|
+
self.print_json(data)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
bids = data.get("bids", [])
|
|
198
|
+
asks = data.get("asks", [])
|
|
199
|
+
|
|
200
|
+
table = Table(title="Order Book")
|
|
201
|
+
table.add_column("Price", justify="right", style="green")
|
|
202
|
+
table.add_column("Size", justify="right")
|
|
203
|
+
table.add_column("Side")
|
|
204
|
+
|
|
205
|
+
# Show asks (reversed, highest first)
|
|
206
|
+
for ask in reversed(asks[:10]):
|
|
207
|
+
table.add_row(str(ask[0]), str(ask[1]), "[red]Ask[/red]")
|
|
208
|
+
|
|
209
|
+
table.add_row("─" * 12, "─" * 10, "─" * 5)
|
|
210
|
+
|
|
211
|
+
# Show bids
|
|
212
|
+
for bid in bids[:10]:
|
|
213
|
+
table.add_row(str(bid[0]), str(bid[1]), "[green]Bid[/green]")
|
|
214
|
+
|
|
215
|
+
self._console.print(table)
|
|
216
|
+
|
|
217
|
+
def print_raw(self, data: Any) -> None:
|
|
218
|
+
"""Print data with best-effort formatting."""
|
|
219
|
+
if self.json_mode:
|
|
220
|
+
self.print_json(data)
|
|
221
|
+
elif isinstance(data, dict):
|
|
222
|
+
self._console.print(data)
|
|
223
|
+
else:
|
|
224
|
+
self._console.print(str(data))
|