initial commit
This commit is contained in:
commit
55475ff7e5
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
*README.md
|
||||||
|
*venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
quotes.db
|
||||||
|
archives/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
5
Quote_Manager_server/.gitignore
vendored
Normal file
5
Quote_Manager_server/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
quotes.db
|
||||||
|
archives/
|
||||||
31
Quote_Manager_server/api_main.py
Normal file
31
Quote_Manager_server/api_main.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from routes import router as quote_router
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Quote Manager",
|
||||||
|
description="Quote Manager",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
origins = [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:5173",
|
||||||
|
]
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# router for quote management
|
||||||
|
app.include_router(quote_router, prefix="/api/quotes")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
|
||||||
31
Quote_Manager_server/batch_ingest.py
Normal file
31
Quote_Manager_server/batch_ingest.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from quote_service import QuoteService
|
||||||
|
from quote_db import QuoteDatabase
|
||||||
|
from quote_contracts import IngestAllRequest
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
|
||||||
|
folder_path = Path(sys.argv[1])
|
||||||
|
else:
|
||||||
|
folder_path = Path("./archives")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if not folder_path.exists() or not folder_path.is_dir():
|
||||||
|
logging.error(f"Sorry, folder {folder_path} does not exist or not a directory.")
|
||||||
|
|
||||||
|
db = QuoteDatabase("quotes.db")
|
||||||
|
service = QuoteService(db)
|
||||||
|
|
||||||
|
|
||||||
|
service.ingest_archives_from_folder(folder_path)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
70
Quote_Manager_server/cli_main.py
Normal file
70
Quote_Manager_server/cli_main.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from quote_service import QuoteService
|
||||||
|
from quote_db import QuoteDatabase
|
||||||
|
from quote_contracts import (
|
||||||
|
FetchQuoteRequest,
|
||||||
|
ListSymbolsRequest,
|
||||||
|
ListDatesRequest,
|
||||||
|
ListSessionRequest,
|
||||||
|
IngestRequest
|
||||||
|
)
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
def to_unix(date_str):
|
||||||
|
return int(datetime.strptime(date_str, "%Y-%m-%d").timestamp())
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Quote Manager Workflow")
|
||||||
|
parser.add_argument("--broker", required=False, help="Broker name")
|
||||||
|
parser.add_argument("--symbol", required=False, help="Symbol")
|
||||||
|
parser.add_argument("--date", required=False, help="Date (YYYY-MM-DD)")
|
||||||
|
parser.add_argument("--archive", required=False, help="Path to archive zip")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
db = QuoteDatabase("quotes.db")
|
||||||
|
service = QuoteService(db)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 1. Ingest archive, redundant since using batch_ingest.py
|
||||||
|
if args.batch_folder:
|
||||||
|
service.ingest_archives_from_folder(Path(args.batch_folder))
|
||||||
|
else:
|
||||||
|
result = service.ingest_archive(IngestRequest(zip_path=args.archive))
|
||||||
|
logging.info(f"{result.status} - {result.message}")
|
||||||
|
|
||||||
|
# 2. List all brokers
|
||||||
|
logging.info("All Brokers:")
|
||||||
|
brokers_response = service.get_all_brokers()
|
||||||
|
logging.info(f"Brokers: {brokers_response.brokers}")
|
||||||
|
|
||||||
|
# 3. List symbols for broker
|
||||||
|
symbols_response = service.get_symbols(ListSymbolsRequest(broker=args.broker))
|
||||||
|
logging.info(f"Symbols for broker '{args.broker}': {symbols_response.symbols}")
|
||||||
|
|
||||||
|
# 4. List dates for symbol
|
||||||
|
dates_response = service.get_dates(ListDatesRequest(broker=args.broker, symbol=args.symbol))
|
||||||
|
logging.info(f"Dates for {args.broker} - {args.symbol}: {dates_response.dates}")
|
||||||
|
|
||||||
|
# 5. List sessions for date
|
||||||
|
sessions_response = service.get_sessions(ListSessionRequest(broker=args.broker, symbol=args.symbol, date=args.date))
|
||||||
|
logging.info(f"Sessions for {args.broker} - {args.symbol} on {args.date}: {sessions_response.sessions}")
|
||||||
|
|
||||||
|
# 6. Fetch quotes
|
||||||
|
start = to_unix(args.date) * 1000
|
||||||
|
end = start + 86400 * 1000
|
||||||
|
quotes_response = service.get_quotes(FetchQuoteRequest(
|
||||||
|
broker=args.broker,
|
||||||
|
symbol=args.symbol,
|
||||||
|
start_time=start,
|
||||||
|
end_time=end
|
||||||
|
))
|
||||||
|
logging.info(f"Quotes fetched: {quotes_response.quotes}")
|
||||||
|
|
||||||
|
|
||||||
68
Quote_Manager_server/ingest.py
Normal file
68
Quote_Manager_server/ingest.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import zipfile
|
||||||
|
import logging
|
||||||
|
from quote_db import QuoteDatabase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def extract_metadata_from_filename(filename):
|
||||||
|
parts = filename.replace(".csv", "").split("_")
|
||||||
|
broker, symbol, *_session_parts = parts
|
||||||
|
session_id = "_".join(_session_parts)
|
||||||
|
return broker, symbol, session_id
|
||||||
|
|
||||||
|
def ingest_zip_archive(zip_path: str, db: QuoteDatabase):
|
||||||
|
archive_name = os.path.basename(zip_path).replace(".zip", "")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
file_list = zip_ref.infolist()
|
||||||
|
|
||||||
|
for file_info in file_list:
|
||||||
|
if file_info.filename.endswith(".csv"):
|
||||||
|
logger.info(f"Processing the CSV file: {file_info.filename}")
|
||||||
|
|
||||||
|
with zip_ref.open(file_info.filename) as file:
|
||||||
|
broker, symbol, session_id = extract_metadata_from_filename(file_info.filename)
|
||||||
|
|
||||||
|
reader = csv.DictReader((line.decode("utf-8") for line in file))
|
||||||
|
quotes = []
|
||||||
|
timestamps = []
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
try:
|
||||||
|
timestamp = int(row["Ts"])
|
||||||
|
bid = float(row["Bid"])
|
||||||
|
ask = float(row["Ask"])
|
||||||
|
|
||||||
|
quotes.append((timestamp, bid, ask))
|
||||||
|
timestamps.append(timestamp)
|
||||||
|
|
||||||
|
except KeyError as e:
|
||||||
|
logger.warning(f"Missing column in row: {row} -- Error found says: {e}")
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Conversion error in row: {row} -- Error found says: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing row: {row} -- Error found says: {e}")
|
||||||
|
|
||||||
|
if quotes:
|
||||||
|
logger.info(f"Parsed {len(quotes)} quotes for session {session_id}")
|
||||||
|
|
||||||
|
|
||||||
|
start_time = min(timestamps)
|
||||||
|
end_time = max(timestamps)
|
||||||
|
|
||||||
|
|
||||||
|
if db.session_exists(session_id):
|
||||||
|
logger.info(f"Session {session_id} already exists in database, skipping insertion.")
|
||||||
|
else:
|
||||||
|
db.insert_session((session_id, broker, symbol, archive_name, start_time, end_time))
|
||||||
|
db.insert_quotes_bulk(session_id, quotes)
|
||||||
|
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"No quotes parsed from the archive file: {file_info.filename}")
|
||||||
|
|
||||||
|
|
||||||
|
db.conn.commit()
|
||||||
|
logger.info(f"Ingestion completed for archive file: {archive_name}")
|
||||||
79
Quote_Manager_server/quote_contracts.py
Normal file
79
Quote_Manager_server/quote_contracts.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FetchData(BaseModel):
|
||||||
|
|
||||||
|
bid_price: Optional[float] = Field(default=None)
|
||||||
|
ask_price: Optional[float] = Field(default=None)
|
||||||
|
timestamp: str
|
||||||
|
broker: str
|
||||||
|
symbol: str
|
||||||
|
spread: Optional[float] = Field(default=None)
|
||||||
|
midline: Optional[float] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class FetchDataResponse(BaseModel):
|
||||||
|
data: List[FetchData]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FetchQuoteRequest(BaseModel):
|
||||||
|
broker: str
|
||||||
|
symbol: Optional[str] = None
|
||||||
|
date: Optional[str] = None
|
||||||
|
start_time: Optional[int] = None
|
||||||
|
end_time: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteResponse(BaseModel):
|
||||||
|
timestamp: int
|
||||||
|
bid: float
|
||||||
|
ask: float
|
||||||
|
session_id: str
|
||||||
|
|
||||||
|
class BrokersSymbolsResponse(BaseModel):
|
||||||
|
brokers: Dict[str, List[str]]
|
||||||
|
|
||||||
|
class FetchQuoteResponse(BaseModel):
|
||||||
|
quotes: List[QuoteResponse]
|
||||||
|
|
||||||
|
class FetchBrokersResponse(BaseModel):
|
||||||
|
brokers: List[str]
|
||||||
|
|
||||||
|
class ListSymbolsRequest(BaseModel):
|
||||||
|
broker: str
|
||||||
|
|
||||||
|
class ListSymbolsResponse(BaseModel):
|
||||||
|
symbols: List[str]
|
||||||
|
|
||||||
|
class ListDatesRequest(BaseModel):
|
||||||
|
broker: str
|
||||||
|
symbol: str
|
||||||
|
|
||||||
|
class ListDatesResponse(BaseModel):
|
||||||
|
dates: List[str]
|
||||||
|
|
||||||
|
class ListSessionRequest(BaseModel):
|
||||||
|
broker: str
|
||||||
|
symbol: str
|
||||||
|
date: str
|
||||||
|
|
||||||
|
class ListSessionResponse(BaseModel):
|
||||||
|
sessions: List[str]
|
||||||
|
|
||||||
|
class IngestRequest(BaseModel):
|
||||||
|
zip_path: str
|
||||||
|
class IngestResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
class IngestAllRequest(BaseModel):
|
||||||
|
folder_path: str
|
||||||
|
|
||||||
|
class IngestAllResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
results: List[IngestResponse]
|
||||||
|
|
||||||
267
Quote_Manager_server/quote_db.py
Normal file
267
Quote_Manager_server/quote_db.py
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import sqlite3
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import logging
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class QuoteDatabase:
|
||||||
|
db_path = "quotes.db"
|
||||||
|
|
||||||
|
def __init__(self, db_path=db_path):
|
||||||
|
self.conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
self.conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
self._create_tables()
|
||||||
|
|
||||||
|
def _create_tables(self):
|
||||||
|
if self.conn:
|
||||||
|
try:
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
broker TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
archive_name TEXT NOT NULL,
|
||||||
|
start_time INTEGER NOT NULL,
|
||||||
|
end_time INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS quotes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
bid REAL NOT NULL,
|
||||||
|
ask REAL NOT NULL,
|
||||||
|
FOREIGN KEY(session_id) REFERENCES sessions(session_id),
|
||||||
|
UNIQUE(session_id, timestamp) ON CONFLICT REPLACE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.conn.commit()
|
||||||
|
logger.info(f"Table has been created succesfully")
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error(f"Error while creating the table")
|
||||||
|
else:
|
||||||
|
logger.info(f"Database connection did not activate, check connection")
|
||||||
|
|
||||||
|
def session_exists(self, session_id: str) -> bool:
|
||||||
|
query = "SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1"
|
||||||
|
cursor = self.conn.execute(query, (session_id,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result is not None:
|
||||||
|
logging.error(f"This session has existed somewhere on the database before, kindly confirm.")
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
logging.info(f"Session is not on database before, processing.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def insert_session(self, session_data: Tuple[str, str, str, str, int, int]):
|
||||||
|
logger.info(f"Inserting session: {session_data[0]}")
|
||||||
|
"""Insert a session: (session_id, broker, symbol, archive_name, start_time, end_time)"""
|
||||||
|
self.conn.execute("""
|
||||||
|
INSERT INTO sessions (session_id, broker, symbol, archive_name, start_time, end_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", session_data)
|
||||||
|
self.conn.commit()
|
||||||
|
logger.info(f"Inserted session: {session_data[0]}, time range: {session_data[4]} to {session_data[5]}")
|
||||||
|
|
||||||
|
def insert_quotes_bulk(self, session_id: str, quotes: List[Tuple[int, float, float]]):
|
||||||
|
if not quotes:
|
||||||
|
logger.warning(f"Skipped empty quote list for session {session_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO quotes (session_id, timestamp, bid, ask)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id, timestamp)
|
||||||
|
DO UPDATE SET
|
||||||
|
bid = excluded.bid,
|
||||||
|
ask = excluded.ask
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.conn.executemany(query, [(session_id, *q) for q in quotes])
|
||||||
|
self.conn.commit()
|
||||||
|
logger.info(f"Quotes inserted successfully for session {session_id} ({len(quotes)} quotes)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error inserting quotes for session {session_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_quotes(
|
||||||
|
self,
|
||||||
|
broker: Optional[str] = None,
|
||||||
|
symbol: Optional[str] = None,
|
||||||
|
start_time: Optional[int] = None,
|
||||||
|
end_time: Optional[int] = None
|
||||||
|
) -> List[Tuple[str, str, float, float]]:
|
||||||
|
self.conn.execute("""
|
||||||
|
SELECT q.session_id, q.timestamp, q.bid, q.ask
|
||||||
|
FROM quotes q
|
||||||
|
JOIN sessions s ON q.session_id = s.session_id
|
||||||
|
WHERE 1=1
|
||||||
|
""")
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if broker is not None:
|
||||||
|
query += " AND s.broker = ?"
|
||||||
|
params.append(broker)
|
||||||
|
if symbol is not None:
|
||||||
|
query += " AND s.symbol = ?"
|
||||||
|
params.append(symbol)
|
||||||
|
if start_time is not None:
|
||||||
|
query += " AND q.timestamp >= ?"
|
||||||
|
params.append(start_time)
|
||||||
|
if end_time is not None:
|
||||||
|
query += " AND q.timestamp <= ?"
|
||||||
|
params.append(end_time)
|
||||||
|
|
||||||
|
cursor = self.conn.execute(query, params)
|
||||||
|
results = cursor.fetchall()
|
||||||
|
|
||||||
|
def to_iso(ts: int) -> str:
|
||||||
|
if ts > 1e12:
|
||||||
|
ts = ts / 1000
|
||||||
|
return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for session_id, timestamp, bid, ask in results:
|
||||||
|
result.append((session_id, to_iso(timestamp), bid, ask))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_brokers(self) -> List[str]:
|
||||||
|
cursor = self.conn.execute("SELECT DISTINCT broker FROM sessions")
|
||||||
|
result = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
result.append(row[0])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_symbols_by_broker(self, broker: str) -> List[str]:
|
||||||
|
cursor = self.conn.execute("""
|
||||||
|
SELECT DISTINCT symbol FROM sessions
|
||||||
|
WHERE broker = ?
|
||||||
|
""", (broker,))
|
||||||
|
result = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
result.append(row[0])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_brokers_and_symbols(self):
|
||||||
|
cursor = self.conn.execute("SELECT DISTINCT broker FROM sessions")
|
||||||
|
brokers_list = [row[0] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for broker in brokers_list:
|
||||||
|
result[broker] = self.get_symbols_by_broker(broker)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_dates_by_broker_symbol(self, broker: str, symbol: str) -> List[str]:
|
||||||
|
cursor = self.conn.execute("""
|
||||||
|
SELECT DISTINCT DATE(q.timestamp / 1000, 'unixepoch')
|
||||||
|
FROM quotes q
|
||||||
|
JOIN sessions s ON q.session_id = s.session_id
|
||||||
|
WHERE s.broker = ? AND s.symbol = ?
|
||||||
|
ORDER BY 1
|
||||||
|
""", (broker, symbol))
|
||||||
|
result = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
result.append(row[0])
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_sessions_by_date(self, broker: str, symbol: str, date: str) -> List[str]:
|
||||||
|
cursor = self.conn.execute("""
|
||||||
|
SELECT DISTINCT s.session_id
|
||||||
|
FROM sessions s
|
||||||
|
JOIN quotes q ON s.session_id = q.session_id
|
||||||
|
WHERE s.broker = ? AND s.symbol = ? AND DATE(q.timestamp / 1000, 'unixepoch') = ?
|
||||||
|
""", (broker, symbol, date))
|
||||||
|
result = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
result.append(row[0])
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_quotes_by_session(self, session_id: str) -> List[Tuple[str, float, float]]:
|
||||||
|
cursor = self.conn.execute("""
|
||||||
|
SELECT datetime(timestamp, 'unixepoch') as ts, bid, ask
|
||||||
|
FROM quotes
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY timestamp
|
||||||
|
""", (session_id,))
|
||||||
|
return cursor.fetchall()
|
||||||
|
|
||||||
|
def get_sessions_by_date_range(self, broker: str, symbol: str, date: str) -> List[str]:
|
||||||
|
cursor = self.conn.execute("""
|
||||||
|
SELECT session_id
|
||||||
|
FROM sessions
|
||||||
|
WHERE broker = ? AND symbol = ? AND DATE(start_time, 'unixepoch') <= ? AND DATE(end_time, 'unixepoch') >= ?
|
||||||
|
ORDER BY start_time
|
||||||
|
""", (broker, symbol, date, date))
|
||||||
|
result = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
result.append(row[0])
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_data(self, broker_a, symbol_a, broker_b, symbol_b, limit=1000, time_range_hours='all'):
|
||||||
|
try:
|
||||||
|
|
||||||
|
available_brokers = pd.read_sql_query("SELECT DISTINCT broker FROM sessions", self.conn)['broker'].tolist()
|
||||||
|
available_symbols = pd.read_sql_query("SELECT DISTINCT symbol FROM sessions", self.conn)['symbol'].tolist()
|
||||||
|
if broker_a not in available_brokers or broker_b not in available_brokers:
|
||||||
|
print(f"Broker not found: broker_a={broker_a}, broker_b={broker_b}, available={available_brokers}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
if symbol_a not in available_symbols or symbol_b not in available_symbols:
|
||||||
|
print(f"Symbol not found: symbol_a={symbol_a}, symbol_b={symbol_b}, available={available_symbols}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT q.session_id, q.bid, q.ask, q.timestamp, s.broker, s.symbol
|
||||||
|
FROM quotes q
|
||||||
|
JOIN sessions s ON q.session_id = s.session_id
|
||||||
|
WHERE (
|
||||||
|
(s.broker = ? AND s.symbol = ?) OR
|
||||||
|
(s.broker = ? AND s.symbol = ?)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params = [broker_a, symbol_a, broker_b, symbol_b]
|
||||||
|
|
||||||
|
|
||||||
|
if time_range_hours != 'all':
|
||||||
|
current_time_ms = int(datetime.now().timestamp() * 1000)
|
||||||
|
time_range_ms = int(time_range_hours) * 3600 * 1000
|
||||||
|
query += " AND q.timestamp >= ?"
|
||||||
|
params.append(current_time_ms - time_range_ms)
|
||||||
|
|
||||||
|
query += " ORDER BY q.timestamp DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
df = pd.read_sql_query(query, self.conn, params=tuple(params))
|
||||||
|
if df.empty:
|
||||||
|
print(f"No data returned for query: broker_a={broker_a}, symbol_a={symbol_a}, "
|
||||||
|
f"broker_b={broker_b}, symbol_b={symbol_b}, time_range={time_range_hours}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms', errors='coerce')
|
||||||
|
|
||||||
|
return df
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in get_data: {e}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.conn:
|
||||||
|
self.conn.close()
|
||||||
|
def __del__(self):
|
||||||
|
self.close()
|
||||||
223
Quote_Manager_server/quote_service.py
Normal file
223
Quote_Manager_server/quote_service.py
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
from typing import Union
|
||||||
|
from sqlalchemy import Float
|
||||||
|
from ingest import ingest_zip_archive
|
||||||
|
from quote_db import QuoteDatabase
|
||||||
|
from quote_contracts import (
|
||||||
|
FetchQuoteRequest,
|
||||||
|
QuoteResponse,
|
||||||
|
FetchQuoteResponse,
|
||||||
|
FetchBrokersResponse,
|
||||||
|
ListSymbolsRequest,
|
||||||
|
ListSymbolsResponse,
|
||||||
|
ListDatesRequest,
|
||||||
|
ListDatesResponse,
|
||||||
|
ListSessionRequest,
|
||||||
|
ListSessionResponse,
|
||||||
|
IngestRequest,
|
||||||
|
IngestResponse,
|
||||||
|
FetchData,
|
||||||
|
FetchDataResponse,
|
||||||
|
BrokersSymbolsResponse,
|
||||||
|
IngestAllRequest,
|
||||||
|
IngestAllResponse
|
||||||
|
)
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class QuoteService:
|
||||||
|
def __init__(self, db: QuoteDatabase):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_archives_from_folder(self, folder_path: Union[Path, IngestAllRequest]) -> IngestAllResponse:
|
||||||
|
results = []
|
||||||
|
if isinstance(folder_path, IngestAllRequest):
|
||||||
|
folder_path = Path(folder_path.folder_path)
|
||||||
|
else:
|
||||||
|
folder_path = folder_path
|
||||||
|
|
||||||
|
if folder_path.exists():
|
||||||
|
if folder_path.is_dir():
|
||||||
|
|
||||||
|
for zip_file in folder_path.rglob("*.zip"):
|
||||||
|
ingest_request = IngestRequest(zip_path=str(zip_file))
|
||||||
|
|
||||||
|
|
||||||
|
response = self.ingest_archive(ingest_request)
|
||||||
|
results.append(response)
|
||||||
|
|
||||||
|
return IngestAllResponse(
|
||||||
|
status = "All done cheers.",
|
||||||
|
results = results
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return IngestAllResponse(
|
||||||
|
status = "Oops!, this is not a directory",
|
||||||
|
results = [
|
||||||
|
IngestResponse(
|
||||||
|
status= "There is an error",
|
||||||
|
message= "Select a folder please!"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return IngestAllResponse(
|
||||||
|
status = "Oops!, this path does not exist",
|
||||||
|
results = [
|
||||||
|
IngestResponse(
|
||||||
|
status= "There is an error",
|
||||||
|
message= "Invalid path to directory selected"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_archive(self, request: IngestRequest) -> IngestResponse:
|
||||||
|
"""Ingest a ZIP archive into the database."""
|
||||||
|
zip_path_str = request.zip_path
|
||||||
|
zip_path = Path(zip_path_str)
|
||||||
|
|
||||||
|
if not zip_path.exists():
|
||||||
|
logger.warning(f"ZIP archive not found: {zip_path}")
|
||||||
|
return IngestResponse(status="failed", message="ZIP archive not found.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ingest_zip_archive(zip_path, db=self.db)
|
||||||
|
return IngestResponse(
|
||||||
|
status="success",
|
||||||
|
message="Archive ingested successfully."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ingestion error: {e}", exc_info=True)
|
||||||
|
return IngestResponse(
|
||||||
|
status="failed",
|
||||||
|
message=f"Ingestion error: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_brokers(self) -> FetchBrokersResponse:
|
||||||
|
"""Return all brokers in the database."""
|
||||||
|
brokers = self.db.get_all_brokers()
|
||||||
|
return FetchBrokersResponse(brokers=brokers)
|
||||||
|
|
||||||
|
def get_brokers_and_symbols(self) -> BrokersSymbolsResponse:
|
||||||
|
"""Return all brokers and their symbols in mapping form."""
|
||||||
|
mapping = self.db.get_brokers_and_symbols()
|
||||||
|
return BrokersSymbolsResponse(brokers=mapping)
|
||||||
|
|
||||||
|
def get_symbols(self, request: ListSymbolsRequest) -> ListSymbolsResponse:
|
||||||
|
"""Return all symbols for a broker."""
|
||||||
|
symbols = self.db.get_symbols_by_broker(request.broker)
|
||||||
|
return ListSymbolsResponse(symbols=symbols)
|
||||||
|
|
||||||
|
def get_dates(self, request: ListDatesRequest) -> ListDatesResponse:
|
||||||
|
"""Return all dates for a broker and symbol."""
|
||||||
|
dates = self.db.get_dates_by_broker_symbol(request.broker, request.symbol)
|
||||||
|
return ListDatesResponse(dates=dates)
|
||||||
|
|
||||||
|
def get_sessions(self, request: ListSessionRequest) -> ListSessionResponse:
|
||||||
|
"""Return all sessions for a broker, symbol, and date."""
|
||||||
|
sessions = self.db.get_sessions_by_date(
|
||||||
|
request.broker, request.symbol, request.date
|
||||||
|
)
|
||||||
|
return ListSessionResponse(sessions=sessions)
|
||||||
|
|
||||||
|
def get_quotes(self, request: FetchQuoteRequest) -> FetchQuoteResponse:
|
||||||
|
"""Return all quotes for a broker, symbol, and time range."""
|
||||||
|
quotes = self.db.fetch_quotes(
|
||||||
|
broker=request.broker,
|
||||||
|
symbol=request.symbol,
|
||||||
|
start_time=request.start_time,
|
||||||
|
end_time=request.end_time
|
||||||
|
)
|
||||||
|
return FetchQuoteResponse(
|
||||||
|
quotes=[
|
||||||
|
QuoteResponse(
|
||||||
|
session_id=row[0],
|
||||||
|
timestamp=row[1],
|
||||||
|
bid=row[2],
|
||||||
|
ask=row[3]
|
||||||
|
)
|
||||||
|
for row in quotes
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def get_data(self, broker_a, symbol_a, broker_b, symbol_b, limit=1000, time_range_hours='all'):
|
||||||
|
df = self.db.get_data(broker_a, symbol_a, broker_b, symbol_b, limit, time_range_hours)
|
||||||
|
if df.empty:
|
||||||
|
print(f"No data after initial fetch: broker_a={broker_a}, symbol_a={symbol_a}, "
|
||||||
|
f"broker_b={broker_b}, symbol_b={symbol_b}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
df_a = df[(df['broker'] == broker_a) & (df['symbol'] == symbol_a)].copy()
|
||||||
|
df_b = df[(df['broker'] == broker_b) & (df['symbol'] == symbol_b)].copy()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
df_a['spread'] = df_a['ask'] - df_a['bid']
|
||||||
|
df_b['spread'] = df_b['ask'] - df_b['bid']
|
||||||
|
df_a['midline'] = (df_a['ask'] + df_a['bid']) / 2
|
||||||
|
df_b['midline'] = (df_b['ask'] + df_b['bid']) / 2
|
||||||
|
|
||||||
|
|
||||||
|
if not df_a.empty:
|
||||||
|
df_a = df_a.dropna(subset=['timestamp'])
|
||||||
|
if df_a.empty:
|
||||||
|
print(f"No valid data for {broker_a}/{symbol_a} after dropping invalid timestamps")
|
||||||
|
else:
|
||||||
|
result_a = [
|
||||||
|
FetchData(
|
||||||
|
# session_id=row['session_id'],
|
||||||
|
bid_price=(row['bid']) if pd.notna(row['bid']) else None,
|
||||||
|
ask_price=(row['ask']) if pd.notna(row['ask']) else None,
|
||||||
|
timestamp=str(row['timestamp']),
|
||||||
|
broker=broker_a,
|
||||||
|
symbol=symbol_a,
|
||||||
|
spread=row['spread'] if pd.notna(row['spread']) else None,
|
||||||
|
midline=row['midline'] if pd.notna(row['midline']) else None
|
||||||
|
)
|
||||||
|
for _, row in df_a.iterrows()
|
||||||
|
|
||||||
|
]
|
||||||
|
result.extend(result_a)
|
||||||
|
|
||||||
|
|
||||||
|
if not df_b.empty:
|
||||||
|
df_b = df_b.dropna(subset=['timestamp'])
|
||||||
|
if df_b.empty:
|
||||||
|
print(f"No valid data for {broker_b}/{symbol_b} after dropping invalid timestamps")
|
||||||
|
else:
|
||||||
|
result_b = [
|
||||||
|
FetchData(
|
||||||
|
# session_id=row['session_id'],
|
||||||
|
bid_price=(row['bid']) if pd.notna(row['bid']) else None,
|
||||||
|
ask_price=(row['ask']) if pd.notna(row['ask']) else None,
|
||||||
|
timestamp=str(row['timestamp']),
|
||||||
|
broker=broker_b,
|
||||||
|
symbol=symbol_b,
|
||||||
|
spread=row['spread'] if pd.notna(row['spread']) else None,
|
||||||
|
midline=row['midline'] if pd.notna(row['midline']) else None
|
||||||
|
|
||||||
|
)
|
||||||
|
for _, row in df_b.iterrows()
|
||||||
|
|
||||||
|
]
|
||||||
|
result.extend(result_b)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
print(f"No valid data after processing: broker_a={broker_a}, symbol_a={symbol_a}, "
|
||||||
|
f"broker_b={broker_b}, symbol_b={symbol_b}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
return result[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BIN
Quote_Manager_server/quotes.db-journal
Normal file
BIN
Quote_Manager_server/quotes.db-journal
Normal file
Binary file not shown.
23
Quote_Manager_server/requirements.txt
Normal file
23
Quote_Manager_server/requirements.txt
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.10.0
|
||||||
|
click==8.2.1
|
||||||
|
fastapi==0.116.1
|
||||||
|
greenlet==3.2.4
|
||||||
|
h11==0.16.0
|
||||||
|
idna==3.10
|
||||||
|
logging==0.4.9.6
|
||||||
|
numpy==2.3.2
|
||||||
|
pandas==2.3.1
|
||||||
|
pathlib==1.0.1
|
||||||
|
pydantic==2.11.7
|
||||||
|
pydantic_core==2.33.2
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
pytz==2025.2
|
||||||
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
SQLAlchemy==2.0.43
|
||||||
|
starlette==0.47.2
|
||||||
|
typing-inspection==0.4.1
|
||||||
|
typing_extensions==4.14.1
|
||||||
|
tzdata==2025.2
|
||||||
|
uvicorn==0.35.0
|
||||||
23
Quote_Manager_server/reset_db.py
Normal file
23
Quote_Manager_server/reset_db.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from quote_db import QuoteDatabase
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
DB_PATH = "quotes.db"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
if os.path.exists(DB_PATH):
|
||||||
|
os.remove(DB_PATH)
|
||||||
|
logging.info(f"Deleted old database: {DB_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
my_class = QuoteDatabase()
|
||||||
|
my_class._create_tables()
|
||||||
|
|
||||||
|
logging.info(f"Fresh database has been created: {DB_PATH}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error resetting database: {e}", exc_info=True)
|
||||||
|
|
||||||
74
Quote_Manager_server/routes.py
Normal file
74
Quote_Manager_server/routes.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
from quote_db import QuoteDatabase
|
||||||
|
from quote_service import QuoteService
|
||||||
|
from quote_contracts import (
|
||||||
|
FetchQuoteRequest, FetchQuoteResponse,
|
||||||
|
ListSymbolsRequest, ListSymbolsResponse,
|
||||||
|
ListDatesRequest, ListDatesResponse,
|
||||||
|
ListSessionRequest, ListSessionResponse,
|
||||||
|
IngestRequest, IngestResponse,
|
||||||
|
FetchBrokersResponse, FetchDataResponse,
|
||||||
|
BrokersSymbolsResponse, IngestAllRequest, IngestAllResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = QuoteDatabase("quotes.db")
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.conn.close()
|
||||||
|
|
||||||
|
def get_service(db: QuoteDatabase = Depends(get_db)):
|
||||||
|
return QuoteService(db=db)
|
||||||
|
|
||||||
|
@router.post("/ingestall", response_model = IngestAllResponse)
|
||||||
|
def ingest_archives(request: IngestAllRequest, service: QuoteService = Depends(get_service)):
|
||||||
|
return service.ingest_archives_from_folder(request)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ingest", response_model=IngestResponse)
|
||||||
|
def ingest_quotes(request: IngestRequest, service: QuoteService = Depends(get_service)):
|
||||||
|
return service.ingest_archive(request)
|
||||||
|
|
||||||
|
@router.get("/api/data", response_model=FetchDataResponse)
|
||||||
|
async def get_data(
|
||||||
|
broker_a: str = Query(...),
|
||||||
|
symbol_a: str = Query(...),
|
||||||
|
broker_b: str = Query(...),
|
||||||
|
symbol_b: str = Query(...),
|
||||||
|
time_range_hours: str = Query('all', description="Time range: 'all' or hours (1, 6, 24)"),
|
||||||
|
limit: int = Query(1000, description="Maximum number of records"),
|
||||||
|
service: QuoteService = Depends(get_service)
|
||||||
|
):
|
||||||
|
data = service.get_data(broker_a, symbol_a, broker_b, symbol_b, limit, time_range_hours)
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
@router.get("/brokers", response_model=FetchBrokersResponse)
|
||||||
|
def get_all_brokers(service: QuoteService = Depends(get_service)):
|
||||||
|
return service.get_all_brokers()
|
||||||
|
|
||||||
|
@router.post("/symbols", response_model=ListSymbolsResponse)
|
||||||
|
def get_symbols(request: ListSymbolsRequest, service: QuoteService = Depends(get_service)):
|
||||||
|
return service.get_symbols(request)
|
||||||
|
|
||||||
|
@router.post("/dates", response_model=ListDatesResponse)
|
||||||
|
def get_dates(request: ListDatesRequest, service: QuoteService = Depends(get_service)):
|
||||||
|
return service.get_dates(request)
|
||||||
|
|
||||||
|
@router.post("/sessions", response_model=ListSessionResponse)
|
||||||
|
def get_sessions(request: ListSessionRequest, service: QuoteService = Depends(get_service)):
|
||||||
|
return service.get_sessions(request)
|
||||||
|
|
||||||
|
@router.post("/quotes", response_model=FetchQuoteResponse)
|
||||||
|
def get_quotes(request: FetchQuoteRequest, service: QuoteService = Depends(get_service)):
|
||||||
|
return service.get_quotes(request)
|
||||||
|
|
||||||
|
@router.get("/brokers&symbols", response_model=BrokersSymbolsResponse)
|
||||||
|
def get_brokers_and_symbols(service: QuoteService = Depends(get_service)):
|
||||||
|
return service.get_brokers_and_symbols()
|
||||||
24
quote_plotter_client/.gitignore
vendored
Normal file
24
quote_plotter_client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
23
quote_plotter_client/eslint.config.js
Normal file
23
quote_plotter_client/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default tseslint.config([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
quote_plotter_client/index.html
Normal file
13
quote_plotter_client/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Quote Plotter</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3797
quote_plotter_client/package-lock.json
generated
Normal file
3797
quote_plotter_client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
quote_plotter_client/package.json
Normal file
34
quote_plotter_client/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "quote_plotter_vite",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
|
"@tanstack/react-query": "^5.85.3",
|
||||||
|
"echarts-for-react": "^3.0.2",
|
||||||
|
"query-string": "^9.2.2",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"tailwindcss": "^4.1.12",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.39.1",
|
||||||
|
"vite": "^7.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
quote_plotter_client/src/App.css
Normal file
4
quote_plotter_client/src/App.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
23
quote_plotter_client/src/App.tsx
Normal file
23
quote_plotter_client/src/App.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { QueryContextProvider } from './context/querycontext';
|
||||||
|
import DropDownContexts from './components/dropDownComponents';
|
||||||
|
// import TableComponents from './components/tableComponents';
|
||||||
|
import ChartComponents from './components/chartComponents';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-800 mb-1">Quote Plotter</h1>
|
||||||
|
<QueryContextProvider>
|
||||||
|
{<DropDownContexts />}
|
||||||
|
{<ChartComponents />}
|
||||||
|
{/* {<TableComponents />} */}
|
||||||
|
</QueryContextProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)
|
||||||
|
};
|
||||||
|
export default App;
|
||||||
110
quote_plotter_client/src/QueryOptions/apiQueryOptions.tsx
Normal file
110
quote_plotter_client/src/QueryOptions/apiQueryOptions.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import qs from 'query-string';
|
||||||
|
import { type RawBrokerSymbol, type QueryOptions, type QuoteData, type QuoteRecord } from "./dataTypes";
|
||||||
|
|
||||||
|
|
||||||
|
export const useBrokerSymbols = () => {
|
||||||
|
return useQuery<RawBrokerSymbol, Error>({
|
||||||
|
queryKey: ['brokers'],
|
||||||
|
queryFn: async(): Promise<RawBrokerSymbol> => {
|
||||||
|
const response = await fetch('http://localhost:8000/api/quotes/brokers&symbols', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
// console.error('broker and symbol data fetch error:', errorData);
|
||||||
|
throw new Error('Unable to fetch broker and symbols');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 10 * 60 * 1000,
|
||||||
|
refetchOnReconnect: "always",
|
||||||
|
staleTime: 1000 * 30,
|
||||||
|
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function quoteDataLink(params: QueryOptions): string {
|
||||||
|
const query = qs.stringify({
|
||||||
|
broker_a: params.brokerA,
|
||||||
|
symbol_a: params.symbolA,
|
||||||
|
broker_b: params.brokerB,
|
||||||
|
symbol_b: params.symbolB,
|
||||||
|
},
|
||||||
|
{ skipEmptyString: true, skipNull: true });
|
||||||
|
return `http://localhost:8000/api/quotes/api/data?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useQuoteData = (params: QueryOptions) => {
|
||||||
|
return useQuery<QuoteData, Error>({
|
||||||
|
queryKey: ['quoteData', params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const url = quoteDataLink(params);
|
||||||
|
// console.log('Fetching quote data from:', url);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
// console.error('Quote data fetch error:', errorData);
|
||||||
|
throw new Error('Unable to fetch quote data');
|
||||||
|
}
|
||||||
|
const quoteData = await response.json();
|
||||||
|
// console.log('Fetched quote data:', quoteData);
|
||||||
|
|
||||||
|
const records = quoteData.data || [];
|
||||||
|
// console.log('Records:', records);
|
||||||
|
const recordsA = records.filter(
|
||||||
|
(record: QuoteRecord) => record.broker === params.brokerA && record.symbol
|
||||||
|
=== params.symbolA
|
||||||
|
);
|
||||||
|
const recordsB = records.filter(
|
||||||
|
(record: QuoteRecord) => record.broker === params.brokerB && record.symbol
|
||||||
|
=== params.symbolB
|
||||||
|
);
|
||||||
|
// console.log('recordsA:', recordsA, 'recordsB:', recordsB);
|
||||||
|
if (!recordsA || !recordsB) {
|
||||||
|
throw new Error('No matching records found for the selected brokers and symbols');
|
||||||
|
}
|
||||||
|
const timestamps = [...new Set([...recordsA, ...recordsB].map(record => record.timestamp))
|
||||||
|
].sort();
|
||||||
|
const quoteDataResult: QuoteData = {
|
||||||
|
brokerA: params.brokerA,
|
||||||
|
brokerB: params.brokerB,
|
||||||
|
symbolA: params.symbolA,
|
||||||
|
symbolB: params.symbolB,
|
||||||
|
records: timestamps.map(timestamp => {
|
||||||
|
const recordA = recordsA.find((record: QuoteRecord) =>
|
||||||
|
record.timestamp === timestamp) || {};
|
||||||
|
const recordB = recordsB.find((record: QuoteRecord) =>
|
||||||
|
record.timestamp === timestamp) || {};
|
||||||
|
return {
|
||||||
|
askA: recordA.ask_price ?? null,
|
||||||
|
bidA: recordA.bid_price ?? null,
|
||||||
|
askB: recordB.ask_price ?? null,
|
||||||
|
bidB: recordB.bid_price ?? null,
|
||||||
|
spreadA: recordA.spread ?? null,
|
||||||
|
spreadB: recordB.spread ?? null,
|
||||||
|
midlineA: recordA.midline ?? null,
|
||||||
|
midlineB: recordB.midline ?? null,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// console.log('Processed quote data:', quoteDataResult);
|
||||||
|
return quoteDataResult;
|
||||||
|
},
|
||||||
|
enabled: !!params.brokerA && !!params.symbolA && !!params.brokerB && !!params.symbolB,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
refetchOnReconnect: "always",
|
||||||
|
});
|
||||||
|
};
|
||||||
50
quote_plotter_client/src/QueryOptions/dataTypes.tsx
Normal file
50
quote_plotter_client/src/QueryOptions/dataTypes.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
export interface RawBrokerSymbol {
|
||||||
|
brokers: {
|
||||||
|
[brokerName: string]: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryOptions {
|
||||||
|
brokerA: string;
|
||||||
|
symbolA: string;
|
||||||
|
brokerB: string;
|
||||||
|
symbolB: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteRecord {
|
||||||
|
bid_price: number;
|
||||||
|
ask_price: number;
|
||||||
|
spread: number;
|
||||||
|
midline: number;
|
||||||
|
timestamp: string;
|
||||||
|
broker: string;
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteData {
|
||||||
|
brokerA: string;
|
||||||
|
symbolA: string;
|
||||||
|
brokerB: string;
|
||||||
|
symbolB: string;
|
||||||
|
records: {
|
||||||
|
askA: number | null;
|
||||||
|
askB: number | null;
|
||||||
|
bidA: number | null;
|
||||||
|
bidB: number | null;
|
||||||
|
spreadA: number | null;
|
||||||
|
spreadB: number | null;
|
||||||
|
midlineA: number | null;
|
||||||
|
midlineB: number | null;
|
||||||
|
timestamp: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryContextType {
|
||||||
|
queryOptions: QueryOptions;
|
||||||
|
brokers: { [key: string]: string[] };
|
||||||
|
handleBrokerChange: (newOptions: Partial<QueryOptions>) => void;
|
||||||
|
setQueryOptions: React.Dispatch<React.SetStateAction<QueryOptions>>;
|
||||||
|
isLoadingBrokers: boolean;
|
||||||
|
isErrorBrokers: boolean;
|
||||||
|
brokersError: Error| null;
|
||||||
|
}
|
||||||
326
quote_plotter_client/src/components/chartComponents.tsx
Normal file
326
quote_plotter_client/src/components/chartComponents.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import React, {useRef, useState} from "react";
|
||||||
|
import { useQuoteData } from "../QueryOptions/apiQueryOptions";
|
||||||
|
import { useQueryContext } from "../context/querycontext";
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
|
||||||
|
|
||||||
|
const ChartComponents: React.FC = () => {
|
||||||
|
const chartRef = useRef<ReactECharts>(null);
|
||||||
|
const { queryOptions, isLoadingBrokers, isErrorBrokers } =
|
||||||
|
useQueryContext();
|
||||||
|
|
||||||
|
|
||||||
|
const { data: quoteData, isLoading: isLoadingQuotes, isError: isErrorQuotes } =
|
||||||
|
useQuoteData(queryOptions);
|
||||||
|
|
||||||
|
const [showMidline, setShowMidline] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
// console.log('quote data before render:', quoteData,
|
||||||
|
// 'isLoadingQuotes:', isLoadingQuotes,
|
||||||
|
// 'quotesError:', isErrorQuotes);
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoadingBrokers) return <div>Loading brokers...</div>;
|
||||||
|
if (isErrorBrokers) return <div>Error loading brokers</div>;
|
||||||
|
if (isErrorQuotes) return <div>Error loading quote data</div>;
|
||||||
|
if (!quoteData?.records?.length) return <div>No quote data available</div>;
|
||||||
|
|
||||||
|
const datasetSource = quoteData.records.filter(record => record.askA !== null || record.midlineA
|
||||||
|
|| record.bidA !== null || record.askB !== null || record.bidB !== null || record.midlineB)
|
||||||
|
.map(record => ({
|
||||||
|
timestamp: record.timestamp,
|
||||||
|
askA: record.askA,
|
||||||
|
askB: record.askB,
|
||||||
|
bidA: record.bidA,
|
||||||
|
bidB: record.bidB,
|
||||||
|
midlineA: record.midlineA,
|
||||||
|
midlineB: record.midlineB,
|
||||||
|
}));
|
||||||
|
// console.log('datasetSource:', datasetSource);
|
||||||
|
|
||||||
|
const valuesA = datasetSource
|
||||||
|
.flatMap(record => [record.askA, record.bidA, record.midlineA])
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const valuesB = datasetSource
|
||||||
|
.flatMap(record => [record.askB, record.bidB, record.midlineB])
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const buffer = 0.00001;
|
||||||
|
// console.log('ValueA:', valuesA);
|
||||||
|
// console.log('ValueB:', valuesB);
|
||||||
|
|
||||||
|
// console.log('askA values:', datasetSource.map(r => r.askA).filter(v => v !== null));
|
||||||
|
// console.log('askB values:', datasetSource.map(r => r.askB).filter(v => v !== null));
|
||||||
|
// console.log('bidA values:', datasetSource.map(r => r.bidA).filter(v => v !== null));
|
||||||
|
// console.log('bidB values:', datasetSource.map(r => r.bidB).filter(v => v !== null));
|
||||||
|
|
||||||
|
const minValueA = valuesA.length ? Math.min(...valuesA) - buffer : 0;
|
||||||
|
const maxValueA = valuesA.length ? Math.max(...valuesA) + buffer : 100;
|
||||||
|
const minValueB = valuesB.length ? Math.min(...valuesB) - buffer : 0;
|
||||||
|
const maxValueB = valuesB.length ? Math.max(...valuesB) + buffer : 100;
|
||||||
|
// console.log('minValueA:', minValueA);
|
||||||
|
// console.log('maxValueA:', maxValueA);
|
||||||
|
// console.log('minValueB:', minValueB);
|
||||||
|
// console.log('maxValueB:', maxValueB);
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
dataset: {
|
||||||
|
source: datasetSource,
|
||||||
|
dimensions: ['timestamp', 'askA', 'askB', 'bidA', 'bidB', 'midlineA', 'midlineB'],
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const date = new Date(params[0].value[0]);
|
||||||
|
return (
|
||||||
|
`Time: ${date.toLocaleTimeString()}<br>` +
|
||||||
|
params
|
||||||
|
.map((p: any) => `${p.seriesName}: ${p.value[1] != null ? p.value[1].toFixed(5) : 'N/A'}`)
|
||||||
|
.join('<br>')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: [
|
||||||
|
`Ask A (${quoteData.brokerA}/${quoteData.symbolA})`,
|
||||||
|
`Bid A (${quoteData.brokerA}/${quoteData.symbolA})`,
|
||||||
|
`Midline A (${quoteData.brokerA}/${quoteData.symbolA})`,
|
||||||
|
`Ask B (${quoteData.brokerB}/${quoteData.symbolB})`,
|
||||||
|
`Bid B (${quoteData.brokerB}/${quoteData.symbolB})`,
|
||||||
|
`Midline B (${quoteData.brokerB}/${quoteData.symbolB})`,
|
||||||
|
],
|
||||||
|
selected: {
|
||||||
|
[`Ask A (${quoteData.brokerA}/${quoteData.symbolA})`]: !showMidline,
|
||||||
|
[`Ask B (${quoteData.brokerB}/${quoteData.symbolB})`]: !showMidline,
|
||||||
|
[`Bid A (${quoteData.brokerA}/${quoteData.symbolA})`]: !showMidline,
|
||||||
|
[`Bid B (${quoteData.brokerB}/${quoteData.symbolB})`]: !showMidline,
|
||||||
|
[`Midline A (${quoteData.brokerA}/${quoteData.symbolA})`]: showMidline,
|
||||||
|
[`Midline B (${quoteData.brokerB}/${quoteData.symbolB})`]: showMidline,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '5%',
|
||||||
|
right: '5%',
|
||||||
|
bottom: '15%',
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
toolbox: {
|
||||||
|
feature: {
|
||||||
|
saveAsImage: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataZoom: [
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
xAxisIndex: 0,
|
||||||
|
start: 80,
|
||||||
|
end: 100,
|
||||||
|
labelFormatter: (value: number) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'inside',
|
||||||
|
xAxisIndex: 0,
|
||||||
|
start: 80,
|
||||||
|
end: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
yAxisIndex: 0,
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
labelFormatter: (value: number) => value.toFixed(5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'inside',
|
||||||
|
yAxisIndex: 0,
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
labelFormatter: (value: number) => value.toFixed(5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'inside',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xAxis: {
|
||||||
|
type: 'time',
|
||||||
|
name: 'Timestamp',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value: number) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: `${quoteData.brokerA}/${quoteData.symbolA}`,
|
||||||
|
min: minValueA,
|
||||||
|
max: maxValueA,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value: number) => value.toFixed(5),
|
||||||
|
},
|
||||||
|
// alignTicks:true,
|
||||||
|
scale: true,
|
||||||
|
splitNumber: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: `${quoteData.brokerB}/${quoteData.symbolB}`,
|
||||||
|
min: minValueB,
|
||||||
|
max: maxValueB,
|
||||||
|
position: 'right',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value: number) => value.toFixed(5),
|
||||||
|
},
|
||||||
|
// alignTicks:true,
|
||||||
|
scale: true,
|
||||||
|
splitNumber: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: `Ask A (${quoteData.brokerA}/${quoteData.symbolA})`,
|
||||||
|
type: 'line',
|
||||||
|
data: datasetSource
|
||||||
|
.filter(record => record.askA !== null)
|
||||||
|
.map(record => [record.timestamp, record.askA]),
|
||||||
|
yAxisIndex: 0,
|
||||||
|
lineStyle: { color: '#007bff', width: 2 },
|
||||||
|
itemStyle: { color: '#007bff' },
|
||||||
|
showSymbol: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
connectNulls: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Bid A (${quoteData.brokerA}/${quoteData.symbolA})`,
|
||||||
|
type: 'line',
|
||||||
|
data: datasetSource
|
||||||
|
.filter(record => record.bidA !== null)
|
||||||
|
.map(record => [record.timestamp, record.bidA]),
|
||||||
|
yAxisIndex: 0,
|
||||||
|
lineStyle: { color: '#00b7eb', type: 'dashed', width: 2 },
|
||||||
|
itemStyle: { color: '#00b7eb' },
|
||||||
|
showSymbol: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
connectNulls: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Ask B (${quoteData.brokerB}/${quoteData.symbolB})`,
|
||||||
|
type: 'line',
|
||||||
|
data: datasetSource
|
||||||
|
.filter(record => record.askB !== null)
|
||||||
|
.map(record => [record.timestamp, record.askB]),
|
||||||
|
yAxisIndex: 1,
|
||||||
|
lineStyle: { color: '#ff4500', width: 2 },
|
||||||
|
itemStyle: { color: '#ff4500' },
|
||||||
|
showSymbol: true,
|
||||||
|
symbol: 'square',
|
||||||
|
symbolSize: 4,
|
||||||
|
connectNulls: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Bid B (${quoteData.brokerB}/${quoteData.symbolB})`,
|
||||||
|
type: 'line',
|
||||||
|
data: datasetSource
|
||||||
|
.filter(record => record.bidB !== null)
|
||||||
|
.map(record => [record.timestamp, record.bidB]),
|
||||||
|
yAxisIndex: 1,
|
||||||
|
lineStyle: { color: '#ff8c00', type: 'dashed', width: 2 },
|
||||||
|
itemStyle: { color: '#ff8c00' },
|
||||||
|
showSymbol: true,
|
||||||
|
symbol: 'square',
|
||||||
|
symbolSize: 4,
|
||||||
|
connectNulls: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Midline A (${quoteData.brokerA}/${quoteData.symbolA})`,
|
||||||
|
type: 'line',
|
||||||
|
data: datasetSource
|
||||||
|
.filter(record => record.midlineA !== null)
|
||||||
|
.map(record => [record.timestamp, record.midlineA]),
|
||||||
|
yAxisIndex: 0,
|
||||||
|
lineStyle: { color: '#28a745', width: 2 },
|
||||||
|
itemStyle: { color: '#28a745' },
|
||||||
|
showSymbol: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
connectNulls: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Midline B (${quoteData.brokerB}/${quoteData.symbolB})`,
|
||||||
|
type: 'line',
|
||||||
|
data: datasetSource
|
||||||
|
.filter(record => record.midlineB !== null)
|
||||||
|
.map(record => [record.timestamp, record.midlineB]),
|
||||||
|
yAxisIndex: 1,
|
||||||
|
lineStyle: { color: '#9932cc', width: 2 },
|
||||||
|
itemStyle: { color: '#9932cc' },
|
||||||
|
showSymbol: true,
|
||||||
|
symbol: 'square',
|
||||||
|
symbolSize: 4,
|
||||||
|
connectNulls: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className= "p-4">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-500 mb-4">Quote Chart</h2>
|
||||||
|
<div className="flex space-x-4 mb-4">
|
||||||
|
<label className="flex items-center space-x-2 text-gray-700 dark:text-gray-500">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="chartMode"
|
||||||
|
checked={!showMidline}
|
||||||
|
onChange={() => setShowMidline(false)}
|
||||||
|
className="form-radio text-blue-500 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span>Show Ask/Bid</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2 text-gray-700 dark:text-gray-500">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="chartMode"
|
||||||
|
checked={showMidline}
|
||||||
|
onChange={() => setShowMidline(true)}
|
||||||
|
className="form-radio text-blue-500 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span>Show Midline</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactECharts
|
||||||
|
echarts={echarts}
|
||||||
|
ref = {chartRef}
|
||||||
|
option={option}
|
||||||
|
style={{ height: 600, width: '100%' }}
|
||||||
|
opts={{ renderer: 'canvas' }}
|
||||||
|
theme="dark"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartComponents;
|
||||||
111
quote_plotter_client/src/components/dropDownComponents.tsx
Normal file
111
quote_plotter_client/src/components/dropDownComponents.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useQueryContext } from "../context/querycontext";
|
||||||
|
|
||||||
|
|
||||||
|
const DropDownContexts: React.FC = () => {
|
||||||
|
const { queryOptions, brokers, handleBrokerChange, isLoadingBrokers, isErrorBrokers } = useQueryContext();
|
||||||
|
|
||||||
|
const brokerList = Object.keys(brokers || {});
|
||||||
|
|
||||||
|
const symbolsA = queryOptions.brokerA && brokers[queryOptions.brokerA] ?
|
||||||
|
brokers[queryOptions.brokerA] : [];
|
||||||
|
const symbolsB = queryOptions.brokerB && brokers[queryOptions.brokerB] ?
|
||||||
|
brokers[queryOptions.brokerB] : [];
|
||||||
|
|
||||||
|
const onBrokerChange = (key: string, value: string) => {
|
||||||
|
if (key === 'brokerA') {
|
||||||
|
const newSymbolA = symbolsA.includes(queryOptions.symbolA) ? queryOptions.symbolA : '';
|
||||||
|
handleBrokerChange({ ...queryOptions, brokerA: value, symbolA: newSymbolA });
|
||||||
|
} else if (key === 'brokerB') {
|
||||||
|
const newSymbolB = symbolsB.includes(queryOptions.symbolB) ? queryOptions.symbolB : '';
|
||||||
|
handleBrokerChange({ ...queryOptions, brokerB: value, symbolB: newSymbolB });
|
||||||
|
} else {
|
||||||
|
handleBrokerChange({ ...queryOptions, [key]: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoadingBrokers) return <div className="text-center text-gray-500">Loading brokers...</div>;
|
||||||
|
if (isErrorBrokers) return <div className="text-center text-red-500">Error loading brokers</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-500 mb-4">Broker and Symbol Selection</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:space-x-4">
|
||||||
|
<label htmlFor="brokerA" className="text-gray-700 dark:text-gray-500 font-medium mb-1 sm:mb-0">
|
||||||
|
Broker A:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="brokerA"
|
||||||
|
value={queryOptions.brokerA}
|
||||||
|
onChange={(e) => onBrokerChange('brokerA', e.target.value)}
|
||||||
|
className="form-select bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-500 border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full sm:w-48"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Broker A --</option>
|
||||||
|
{brokerList.map((broker) => (
|
||||||
|
<option key={broker} value={broker}>
|
||||||
|
{broker}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label htmlFor="symbolA" className="text-gray-700 dark:text-gray-500 font-medium mb-1 sm:mb-0">
|
||||||
|
Symbol A:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="symbolA"
|
||||||
|
value={queryOptions.symbolA}
|
||||||
|
onChange={(e) => onBrokerChange('symbolA', e.target.value)}
|
||||||
|
className="form-select bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-500 border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full sm:w-48"
|
||||||
|
disabled={!queryOptions.brokerA}
|
||||||
|
>
|
||||||
|
<option value="">-- Select Symbol A --</option>
|
||||||
|
{symbolsA.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:space-x-4">
|
||||||
|
<label htmlFor="brokerB" className="text-gray-700 dark:text-gray-500 font-medium mb-1 sm:mb-0">
|
||||||
|
Broker B:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="brokerB"
|
||||||
|
value={queryOptions.brokerB}
|
||||||
|
onChange={(e) => onBrokerChange('brokerB', e.target.value)}
|
||||||
|
className="form-select bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-500 border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full sm:w-48"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Broker B --</option>
|
||||||
|
{brokerList.map((b) => (
|
||||||
|
<option key={b} value={b}>
|
||||||
|
{b}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label htmlFor="symbolB" className="text-gray-700 dark:text-gray-500 font-medium mb-1 sm:mb-0">
|
||||||
|
Symbol B:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="symbolB"
|
||||||
|
value={queryOptions.symbolB}
|
||||||
|
onChange={(e) => onBrokerChange('symbolB', e.target.value)}
|
||||||
|
className="form-select bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-500 border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full sm:w-48"
|
||||||
|
disabled={!queryOptions.brokerB}
|
||||||
|
>
|
||||||
|
<option value="">-- Select Symbol B --</option>
|
||||||
|
{symbolsB.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DropDownContexts;
|
||||||
90
quote_plotter_client/src/components/tableComponents.tsx
Normal file
90
quote_plotter_client/src/components/tableComponents.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useQuoteData } from "../QueryOptions/apiQueryOptions";
|
||||||
|
import { useQueryContext } from "../context/querycontext";
|
||||||
|
|
||||||
|
const TableComponents: React.FC = () => {
|
||||||
|
const { queryOptions, isLoadingBrokers, isErrorBrokers } =
|
||||||
|
useQueryContext();
|
||||||
|
const { data: quoteData, isLoading: isLoadingQuotes, isError: isErrorQuotes } =
|
||||||
|
useQuoteData(queryOptions);
|
||||||
|
console.log('quote data before render:', quoteData,
|
||||||
|
'isLoadingQuotes:', isLoadingQuotes,
|
||||||
|
'quotesError:', isErrorQuotes);
|
||||||
|
if (isLoadingBrokers) return <div>Loading brokers...</div>;
|
||||||
|
if (isErrorBrokers) return <div>Error loading brokers</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-500 mb-4">Quote Data</h2>
|
||||||
|
{isLoadingQuotes && <div className="text-center text-gray-500">Loading quote data...</div>}
|
||||||
|
{isErrorQuotes && <div className="text-center text-red-500">Error loading quote data</div>}
|
||||||
|
{!isLoadingQuotes && !isErrorQuotes && !quoteData && <div className="text-center text-gray-500">No quote data available</div>}
|
||||||
|
{quoteData && (
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<table className="flex-1 min-w-0 border-collapse border border-gray-300 dark:border-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-white dark:bg-gray-800">
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Timestamp</th>
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Ask A ({quoteData.brokerA}/{quoteData.symbolA})</th>
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Bid A</th>
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Spread A</th>
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Midline A</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{quoteData.records
|
||||||
|
.filter(record =>
|
||||||
|
record.askA !== null ||
|
||||||
|
record.bidA !== null ||
|
||||||
|
record.spreadA !== null ||
|
||||||
|
record.midlineA !== null
|
||||||
|
)
|
||||||
|
.map((record, index) => (
|
||||||
|
<tr key = {index} className="bg-white dark:bg-gray-800">
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.timestamp}</td>
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.askA}</td>
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.bidA}</td>
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.spreadA}</td>
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.midlineA}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table className="flex-1 min-w-0 border-collapse border border-gray-300 dark:border-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-white dark:bg-gray-800">
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Timestamp</th>
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Ask B ({quoteData.brokerB}/{quoteData.symbolB})</th>
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Bid B</th>
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Spread B</th>
|
||||||
|
<th className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-left text-gray-700 dark:text-gray-300">Midline B</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{quoteData.records
|
||||||
|
.filter(record =>
|
||||||
|
record.askB !== null ||
|
||||||
|
record.bidB !== null ||
|
||||||
|
record.spreadB !== null ||
|
||||||
|
record.midlineB !== null
|
||||||
|
)
|
||||||
|
.map((record, index) => (
|
||||||
|
<tr key = {index} className="bg-white dark:bg-gray-800">
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.timestamp}</td>
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.askB}</td>
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.bidB}</td>
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.spreadB}</td>
|
||||||
|
<td className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-4 py-2 text-gray-700 dark:text-gray-300">{record.midlineB}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TableComponents;
|
||||||
42
quote_plotter_client/src/context/querycontext.tsx
Normal file
42
quote_plotter_client/src/context/querycontext.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React, { useState, useContext } from "react";
|
||||||
|
import { type QueryOptions, type QueryContextType } from "../QueryOptions/dataTypes";
|
||||||
|
import { useBrokerSymbols } from '../QueryOptions/apiQueryOptions';
|
||||||
|
|
||||||
|
const QueryContext = React.createContext<QueryContextType |
|
||||||
|
undefined>(undefined);
|
||||||
|
|
||||||
|
export const QueryContextProvider: React.FC<{ children: React.ReactNode }> = ({children }) => {
|
||||||
|
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
|
||||||
|
brokerA: "",
|
||||||
|
symbolA: "",
|
||||||
|
brokerB: "",
|
||||||
|
symbolB: ""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { data: brokersData, isLoading: isLoadingBrokers, isError: isErrorBrokers, error: brokersError } = useBrokerSymbols();
|
||||||
|
const handleBrokerChange = (newOptions: Partial<QueryOptions>) => {
|
||||||
|
setQueryOptions((prev) => ({ ...prev, ...newOptions }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryContext.Provider
|
||||||
|
value={{
|
||||||
|
queryOptions,
|
||||||
|
setQueryOptions,
|
||||||
|
brokers: brokersData?.brokers || {},
|
||||||
|
brokersError,
|
||||||
|
handleBrokerChange,
|
||||||
|
isLoadingBrokers,
|
||||||
|
isErrorBrokers,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</QueryContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useQueryContext = (): QueryContextType => {
|
||||||
|
const context = useContext(QueryContext);
|
||||||
|
if (!context) throw new Error('useQueryContext must be used within a QueryProvider');
|
||||||
|
return context;
|
||||||
|
};
|
||||||
5
quote_plotter_client/src/index.css
Normal file
5
quote_plotter_client/src/index.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
15
quote_plotter_client/src/main.tsx
Normal file
15
quote_plotter_client/src/main.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
11
quote_plotter_client/src/quote_plotter_vite.code-workspace
Normal file
11
quote_plotter_client/src/quote_plotter_vite.code-workspace
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": ".."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../quote_plotter_client"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
27
quote_plotter_client/tsconfig.app.json
Normal file
27
quote_plotter_client/tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
quote_plotter_client/tsconfig.json
Normal file
7
quote_plotter_client/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
quote_plotter_client/tsconfig.node.json
Normal file
25
quote_plotter_client/tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
10
quote_plotter_client/vite.config.ts
Normal file
10
quote_plotter_client/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user