Skip to content

Commit 1468f10

Browse files
authored
feat: Local trader portfolio (jordan-dimov#16)
1 parent f2ca01c commit 1468f10

18 files changed

Lines changed: 843 additions & 300 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# App
2+
ctenex/exchange_client/data/
3+
14
# Byte-compiled / optimized / DLL files
25
__pycache__/
36
*.py[cod]

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ The system exposes its functionality through a FastAPI REST API, allowing:
6666
* Viewing current market orders
6767
* Checking contract specifications
6868

69+
## Exchange client
70+
71+
A simple CLI tool is provided for keeping a local portfolio and interacting with the exchange.
72+
Check out the [Client Exchange section](./docs/client-exchange.md)
73+
6974
## Development
7075

7176
To learn how to use the dev tools available, see the [Dev Tools section](./docs/dev-tools.md)

ctenex/api/v1/controllers/exchange.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from typing import Annotated
22

3-
from fastapi import APIRouter, Body, Request
3+
from fastapi import APIRouter, Body, Depends
44

5+
from ctenex.core.db.async_session import AsyncSessionStream, db
6+
from ctenex.core.db.utils import get_entity_values
57
from ctenex.domain.contracts import ContractCode
68
from ctenex.domain.entities import OpenOrderStatus
79
from ctenex.domain.matching_engine.model import matching_engine
10+
from ctenex.domain.order_book.contract.reader import contracts_reader
11+
from ctenex.domain.order_book.contract.schemas import ContractGetResponse
812
from ctenex.domain.order_book.order.model import Order
913
from ctenex.domain.order_book.order.schemas import OrderAddRequest, OrderAddResponse
1014

@@ -13,7 +17,6 @@
1317

1418
@router.post("/orders")
1519
async def place_order(
16-
request: Request,
1720
body: Annotated[OrderAddRequest, Body()],
1821
) -> OrderAddResponse:
1922
order = Order(**body.model_dump())
@@ -28,8 +31,23 @@ async def place_order(
2831

2932
@router.get("/orders")
3033
async def get_order(
31-
request: Request,
3234
contract_id: ContractCode,
3335
) -> list[Order]:
3436
orders: list[Order] = await matching_engine.get_orders(contract_id)
3537
return orders
38+
39+
40+
@router.get("/supported-contracts")
41+
async def get_supported_contracts(
42+
limit: int = 10,
43+
page: int = 1,
44+
db: AsyncSessionStream = Depends(db),
45+
) -> list[ContractGetResponse]:
46+
contracts = await contracts_reader.get_many(
47+
db=db,
48+
limit=limit,
49+
page=page,
50+
)
51+
return [
52+
ContractGetResponse(**get_entity_values(contract)) for contract in contracts
53+
]

ctenex/domain/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from ctenex.domain.entities import (
2+
Commodity, # noqa: F401
3+
DeliveryPeriod, # noqa: F401
4+
OrderSide, # noqa: F401
5+
OrderStatus, # noqa: F401
6+
OrderType, # noqa: F401
7+
)

ctenex/domain/entities.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,11 @@ class Contract(AbstractBase):
101101
type_=String,
102102
unique=True,
103103
)
104-
commodity: Mapped[str] = mapped_column(
104+
commodity: Mapped[Commodity] = mapped_column(
105105
type_=String,
106106
nullable=False,
107107
)
108-
delivery_period: Mapped[str] = mapped_column(
108+
delivery_period: Mapped[DeliveryPeriod] = mapped_column(
109109
type_=String,
110110
nullable=False,
111111
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from ctenex.core.data_access.reader import GenericReader
2+
from ctenex.domain.entities import Contract
3+
4+
5+
class ContractsReader(GenericReader[Contract]):
6+
"""Reader for Book contracts."""
7+
8+
model = Contract
9+
10+
11+
contracts_reader = ContractsReader()
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from datetime import datetime
2+
from decimal import Decimal
3+
4+
from pydantic import BaseModel
5+
6+
7+
class ContractGetResponse(BaseModel):
8+
contract_id: str
9+
commodity: str
10+
delivery_period: str
11+
start_date: datetime
12+
end_date: datetime
13+
tick_size: Decimal
14+
contract_size: Decimal
15+
location: str

ctenex/exchange_client/__init__.py

Whitespace-only changes.

ctenex/exchange_client/cli.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from datetime import datetime
2+
from decimal import ROUND_HALF_UP, Decimal
3+
from typing import Optional
4+
5+
import httpx
6+
import typer
7+
8+
from ctenex.domain import OrderSide, OrderType
9+
from ctenex.domain.order_book.contract.schemas import ContractGetResponse
10+
from ctenex.exchange_client.db.connection import get_db_connection
11+
from ctenex.exchange_client.db.init import init_db
12+
13+
14+
# TODO: Move to settings
15+
def get_api_url() -> str:
16+
"""Get the API URL from environment or default to localhost."""
17+
return "http://0.0.0.0:8000" # Default to localhost, can be overridden
18+
19+
20+
app = typer.Typer()
21+
22+
23+
@app.command()
24+
def place_order(
25+
contract_id: str = typer.Argument(..., help="Contract identifier"),
26+
side: OrderSide = typer.Argument(..., help="Order side (BUY/SELL)"),
27+
type: OrderType = typer.Argument(..., help="Order type (LIMIT/MARKET)"),
28+
quantity: float = typer.Argument(..., help="Order quantity"),
29+
price: float = typer.Argument(..., help="Order price (2 decimal places precision)"),
30+
):
31+
"""Place a new order through the exchange API."""
32+
base_url = get_api_url()
33+
34+
# Validate contract ID exists
35+
try:
36+
with httpx.Client() as client:
37+
response = client.get(f"{base_url}/v1/stateless/supported-contracts")
38+
response.raise_for_status()
39+
supported_contracts = response.json()
40+
41+
if contract_id not in [c["contract_id"] for c in supported_contracts]:
42+
typer.echo(f"Error: Contract ID '{contract_id}' is not supported")
43+
raise typer.Exit(1)
44+
except httpx.HTTPError as e:
45+
typer.echo(f"Error validating contract ID: {str(e)}")
46+
raise typer.Exit(1)
47+
48+
# supported_contracts.
49+
contracts = [ContractGetResponse(**c) for c in supported_contracts]
50+
tick_size = next(
51+
(c.tick_size for c in contracts if c.contract_id == contract_id), None
52+
)
53+
54+
if tick_size is None:
55+
typer.echo(f"Error: Contract ID '{contract_id}' is not supported")
56+
raise typer.Exit(1)
57+
58+
# Convert float price to Decimal with 2 decimal places precision
59+
price_decimal = Decimal(str(price)).quantize(
60+
Decimal(tick_size), rounding=ROUND_HALF_UP
61+
)
62+
quantity_decimal = Decimal(str(quantity)).quantize(
63+
Decimal("0.01"), rounding=ROUND_HALF_UP
64+
)
65+
66+
typer.echo(f"Contract ID: {contract_id}")
67+
typer.echo(f"Side: {side}")
68+
typer.echo(f"Quantity: {quantity_decimal}")
69+
typer.echo(f"Price: {price_decimal}")
70+
71+
# Prepare order data
72+
order_data = {
73+
"contract_id": contract_id,
74+
"trader_id": "1e1590fd-f479-4bd4-ad03-56f2e265ec33", # TODO: Load from local DB
75+
"side": side,
76+
"type": type,
77+
"quantity": str(quantity_decimal),
78+
"price": str(price_decimal), # Convert Decimal to string for JSON serialization
79+
}
80+
81+
try:
82+
with httpx.Client() as client:
83+
response = client.post(f"{base_url}/v1/stateless/orders", json=order_data)
84+
response.raise_for_status()
85+
order_response = response.json()
86+
87+
# Store in DuckDB
88+
with get_db_connection() as conn:
89+
conn.execute(
90+
"""
91+
INSERT INTO orders (id, contract_id, side, quantity, price, status, created_at)
92+
VALUES (?, ?, ?, ?, ?, ?, ?)
93+
""",
94+
(
95+
order_response["id"],
96+
order_response["contract_id"],
97+
order_response["side"],
98+
str(quantity_decimal),
99+
str(price_decimal),
100+
order_response["status"],
101+
datetime.now(),
102+
),
103+
)
104+
105+
typer.echo(f"Order placed successfully! Order ID: {order_response['id']}")
106+
107+
except httpx.HTTPError as e:
108+
typer.echo(f"Error placing order: {str(e)}", err=True)
109+
raise typer.Exit(1)
110+
111+
112+
@app.command()
113+
def list_orders(
114+
contract_id: Optional[str] = typer.Option(None, help="Filter by contract ID"),
115+
):
116+
"""List all stored orders, optionally filtered by contract ID."""
117+
query = "SELECT * FROM orders"
118+
params = []
119+
120+
if contract_id:
121+
query += " WHERE contract_id = ?"
122+
params.append(contract_id)
123+
124+
query += " ORDER BY created_at DESC"
125+
126+
with get_db_connection() as conn:
127+
results = conn.execute(query, params).fetchall()
128+
129+
if not results:
130+
typer.echo("No orders found.")
131+
return
132+
133+
# Print results in a table format
134+
typer.echo("\nStored Orders:")
135+
typer.echo("-" * 120)
136+
typer.echo(
137+
f"{'ID':<40} {'Contract':<15} {'Side':<6} {'Quantity':<10} {'Price':<8} {'Status':<8} {'Created At'}"
138+
)
139+
typer.echo("-" * 120)
140+
141+
for row in results:
142+
typer.echo(
143+
f"{row[0]:<40} {row[1]:<15} {row[2]:<6} {row[3]:<10} {row[4]:<8} {row[5]:<8} {row[6]}"
144+
)
145+
146+
147+
if __name__ == "__main__":
148+
init_db()
149+
app()

ctenex/exchange_client/db/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)