initial commit

This commit is contained in:
coddard
2026-05-18 00:31:08 +03:00
commit 0096c8819b
26 changed files with 2851 additions and 0 deletions
View File
+175
View File
@@ -0,0 +1,175 @@
import asyncio
import json
import logging
import time
from math import floor
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from ib_async import Stock
from sse_starlette.sse import EventSourceResponse
import dependencies
router = APIRouter()
templates = Jinja2Templates(directory="templates")
logger = logging.getLogger(__name__)
_history_cache: dict = {} # {symbol: (monotonic_ts, data)}
_HISTORY_CACHE_TTL = 60 # seconds
class ChartState:
def __init__(self):
self.latest_tick = None
self.current_ticker = None
self.current_minute = None
self.current_ohlc = None
chart_state = ChartState()
@router.get("/", response_class=HTMLResponse)
async def index(request: Request, symbol: str = ""):
return templates.TemplateResponse(request, "index.html", {"symbol": symbol})
@router.get("/history")
async def get_history(symbol: str):
sym = symbol.upper()
now = time.monotonic()
cached = _history_cache.get(sym)
if cached and (now - cached[0]) < _HISTORY_CACHE_TTL:
return cached[1]
try:
ib = dependencies.get_ib()
contract = Stock(sym, "SMART", "USD")
qualified = await ib.qualifyContractsAsync(contract)
if not qualified:
return []
bars = None
for attempt in range(3):
try:
bars = await ib.reqHistoricalDataAsync(
qualified[0],
endDateTime="",
durationStr="1 D",
barSizeSetting="1 min",
whatToShow="TRADES",
useRTH=True,
formatDate=1,
)
break
except Exception as exc:
msg = str(exc)
if ("162" in msg or "pacing" in msg.lower()) and attempt < 2:
logger.warning(
f"IBKR pacing violation for {sym}, retry {attempt + 1}/3 in 10s"
)
await asyncio.sleep(10)
continue
raise
if bars is None:
return []
result = [
{
"time": int(bar.date.timestamp()),
"open": bar.open,
"high": bar.high,
"low": bar.low,
"close": bar.close,
}
for bar in bars
]
_history_cache[sym] = (time.monotonic(), result)
return result
except Exception as exc:
logger.error(f"Error fetching history for {sym}: {exc}")
return []
@router.post("/subscribe")
async def subscribe(request: Request):
try:
ib = dependencies.get_ib()
data = await request.json()
symbol = data.get("symbol", "").upper()
if not symbol:
return {"status": "error", "message": "Symbol is required"}
contract = Stock(symbol, "SMART", "USD")
qualified = await ib.qualifyContractsAsync(contract)
if not qualified:
return {"status": "error", "message": f"Could not qualify contract for {symbol}"}
if chart_state.current_ticker is not None:
try:
ib.cancelRealTimeBars(chart_state.current_ticker)
except Exception as e:
print(f"Error cancelling real-time bars: {e}")
chart_state.current_minute = None
chart_state.current_ohlc = None
ticker = ib.reqRealTimeBars(qualified[0], barSize=5, whatToShow="TRADES", useRTH=True)
chart_state.current_ticker = ticker
def on_bar(bars, hasNewBar):
if not hasNewBar or not bars:
return
bar = bars[-1]
timestamp = int(bar.time.timestamp())
minute_bucket = floor(timestamp / 60) * 60
if chart_state.current_minute is None:
chart_state.current_minute = minute_bucket
chart_state.current_ohlc = {
"time": minute_bucket,
"open": bar.open_,
"high": bar.high,
"low": bar.low,
"close": bar.close,
}
elif minute_bucket == chart_state.current_minute:
chart_state.current_ohlc["high"] = max(chart_state.current_ohlc["high"], bar.high)
chart_state.current_ohlc["low"] = min(chart_state.current_ohlc["low"], bar.low)
chart_state.current_ohlc["close"] = bar.close
else:
print(f"Finalized candle: {chart_state.current_ohlc}")
chart_state.current_minute = minute_bucket
chart_state.current_ohlc = {
"time": minute_bucket,
"open": bar.open_,
"high": bar.high,
"low": bar.low,
"close": bar.close,
}
chart_state.latest_tick = chart_state.current_ohlc.copy()
ticker.updateEvent += on_bar
return {"status": "ok", "symbol": symbol}
except Exception as e:
print(f"Error subscribing to {symbol}: {e}")
return {"status": "error", "message": str(e)}
@router.get("/stream")
async def stream(request: Request):
async def event_generator():
while True:
if await request.is_disconnected():
break
if chart_state.latest_tick:
yield {"event": "candle", "data": json.dumps(chart_state.latest_tick)}
await asyncio.sleep(1)
return EventSourceResponse(event_generator())
+37
View File
@@ -0,0 +1,37 @@
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import dependencies
router = APIRouter()
@router.get("/health")
async def health():
ib_connected = False
account = None
try:
ib_inst = dependencies.get_ib()
ib_connected = ib_inst.isConnected()
accounts = ib_inst.managedAccounts()
account = accounts[0] if accounts else None
except Exception:
pass
db_ok = False
try:
cur = dependencies.get_db_cursor()
cur.execute("SELECT 1")
db_ok = True
except Exception:
pass
return JSONResponse(
{
"status": "ok" if (ib_connected and db_ok) else "degraded",
"ib_connected": ib_connected,
"account": account,
"db_ok": db_ok,
"version": "3.0",
}
)
+48
View File
@@ -0,0 +1,48 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import dependencies
router = APIRouter(prefix="/portfolio")
templates = Jinja2Templates(directory="templates")
@router.get("", response_class=HTMLResponse)
async def portfolio_page(request: Request):
return templates.TemplateResponse(request, "portfolio.html", {})
@router.get("/data")
async def portfolio_data():
try:
ib = dependencies.get_ib()
positions = ib.positions()
return [
{
"symbol": pos.contract.symbol,
"position": pos.position,
"avg_cost": pos.avgCost,
}
for pos in positions
]
except Exception as e:
return {"error": str(e)}
@router.get("/pnl")
async def portfolio_pnl():
try:
ib = dependencies.get_ib()
summary = await ib.reqAccountSummaryAsync()
keys = {"NetLiquidation", "TotalCashValue", "UnrealizedPnL", "RealizedPnL"}
result = {}
for item in summary:
if item.tag in keys:
try:
result[item.tag] = float(item.value)
except (ValueError, TypeError):
result[item.tag] = item.value
return result
except Exception as e:
return {"error": str(e)}
+109
View File
@@ -0,0 +1,109 @@
import asyncio
import csv
import io
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from ib_async import ScannerSubscription
import dependencies
router = APIRouter(prefix="/scanner")
templates = Jinja2Templates(directory="templates")
logger = logging.getLogger(__name__)
async def execute_scan(data: dict, ib) -> list:
scan_type = data.get("scan_type", "HOT_BY_VOLUME")
min_price = float(data.get("min_price", 0) or 0)
min_volume = int(data.get("min_volume", 0) or 0)
min_market_cap = float(data.get("min_market_cap", 0) or 0)
subscription = ScannerSubscription()
subscription.instrument = "STK"
subscription.locationCode = "STK.US.MAJOR"
subscription.stockTypeFilter = "ALL"
subscription.scanCode = scan_type
if min_price > 0:
subscription.abovePrice = min_price
if min_volume > 0:
subscription.aboveVolume = min_volume
if min_market_cap > 0:
subscription.marketCapAbove = min_market_cap
scan_data = await asyncio.wait_for(
ib.reqScannerDataAsync(subscription), timeout=30.0
)
results = []
for item in scan_data[:20]:
contract = item.contractDetails.contract
symbol = contract.symbol
try:
details_list = await ib.reqContractDetailsAsync(contract)
long_name = details_list[0].longName if details_list else None
except Exception:
long_name = None
results.append({
"symbol": symbol,
"company": long_name or contract.localSymbol or f"{symbol} Inc.",
"exchange": contract.primaryExchange or contract.exchange,
"chart_image": f"https://finviz.com/chart.ashx?t={symbol}&ty=c&ta=1&p=d&s=l",
"rank": item.rank if hasattr(item, "rank") else None,
})
return results
@router.get("", response_class=HTMLResponse)
async def scanner_page(request: Request):
return templates.TemplateResponse(request, "scanner.html", {})
@router.post("")
async def run_scanner(request: Request):
try:
ib = dependencies.get_ib()
data = await request.json()
results = await execute_scan(data, ib)
return {"status": "ok", "results": results}
except asyncio.TimeoutError:
return {"status": "error", "message": "Scanner request timed out"}
except Exception as e:
logger.error(f"Scanner error: {e}")
return {"status": "error", "message": str(e)}
@router.post("/export")
async def export_scanner_csv(request: Request):
try:
ib = dependencies.get_ib()
data = await request.json()
scan_type = data.get("scan_type", "HOT_BY_VOLUME")
results = await execute_scan(data, ib)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["Symbol", "Company", "Exchange", "Scan Type", "Timestamp"])
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
for r in results:
writer.writerow([r["symbol"], r["company"], r["exchange"], scan_type, ts])
filename = "scanner_" + datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + ".csv"
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
except asyncio.TimeoutError:
return {"status": "error", "message": "Scanner request timed out"}
except Exception as e:
logger.error(f"Scanner export error: {e}")
return {"status": "error", "message": str(e)}
+171
View File
@@ -0,0 +1,171 @@
import asyncio
import logging
import os
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from ib_async import LimitOrder, MarketOrder, Stock
import dependencies
from risk_manager import RiskManager
router = APIRouter()
templates = Jinja2Templates(directory="templates")
logger = logging.getLogger(__name__)
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "your_super_secret_string_123")
risk = RiskManager()
def log_trade(symbol, action, quantity, price, cursor, conn, lock):
timestamp = int(datetime.now(timezone.utc).timestamp())
with lock:
cursor.execute(
"INSERT INTO trades (timestamp, symbol, action, quantity, price) VALUES (?, ?, ?, ?, ?)",
(timestamp, symbol, action, quantity, price),
)
conn.commit()
logger.info(f"Logged trade: {action} {quantity} {symbol} @ ${price:.2f}")
@router.get("/tradelog", response_class=HTMLResponse)
async def tradelog(request: Request):
cursor = dependencies.get_db_cursor()
cursor.execute(
"SELECT timestamp, symbol, action, quantity, price FROM trades ORDER BY timestamp DESC"
)
rows = cursor.fetchall()
trades = [
{
"timestamp": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S"),
"symbol": symbol,
"action": action,
"quantity": quantity,
"price": price,
}
for ts, symbol, action, quantity, price in rows
]
return templates.TemplateResponse(request, "tradelog.html", {"trades": trades})
@router.get("/risk/status")
async def risk_status():
try:
ib = dependencies.get_ib()
cursor = dependencies.get_db_cursor()
positions = ib.positions()
open_count = len([p for p in positions if p.position != 0])
return {
"limits": {
"max_daily_loss": risk.max_daily_loss,
"max_positions": risk.max_positions,
"max_order_value": risk.max_order_value,
},
"current": {
"open_positions": open_count,
"daily_loss_check_passed": risk.check_daily_loss(cursor),
},
}
except Exception as exc:
return {"error": str(exc)}
@router.post("/webhook")
async def webhook(request: Request):
try:
data = await request.json()
if data.get("secret") != WEBHOOK_SECRET:
raise HTTPException(status_code=403, detail="Invalid secret")
symbol = data["symbol"].upper()
strategy = data["strategy"]
action = strategy["order_action"].upper()
quantity = strategy["order_contracts"]
if action not in ["BUY", "SELL"]:
raise HTTPException(status_code=400, detail=f"Invalid action: {action}")
order_type = data.get("order_type", "MARKET").upper()
limit_price = data.get("limit_price")
take_profit = data.get("take_profit")
stop_loss = data.get("stop_loss")
# Validate order parameters before any IB calls
if order_type == "LIMIT" and limit_price is None:
raise HTTPException(status_code=400, detail="limit_price required for LIMIT orders")
if order_type == "BRACKET" and (
limit_price is None or take_profit is None or stop_loss is None
):
raise HTTPException(
status_code=400,
detail="limit_price, take_profit, and stop_loss required for BRACKET orders",
)
ib = dependencies.get_ib()
cursor = dependencies.get_db_cursor()
conn = dependencies.get_db_conn()
lock = dependencies.get_db_lock()
est_price = float(limit_price) if limit_price else 1.0
risk_result = await risk.run_all_checks(ib, cursor, quantity, est_price)
if not risk_result["passed"]:
raise HTTPException(
status_code=429,
detail=f"Risk check failed: {', '.join(risk_result['failed_checks'])}",
)
# Qualify contract before placing order (C3)
raw_contract = Stock(symbol, "SMART", "USD")
qualified = await ib.qualifyContractsAsync(raw_contract)
if not qualified:
raise HTTPException(
status_code=400, detail=f"Could not qualify contract for {symbol}"
)
contract = qualified[0]
if order_type == "LIMIT":
order = LimitOrder(action, quantity, float(limit_price))
trades_list = [ib.placeOrder(contract, order)]
elif order_type == "BRACKET":
bracket = ib.bracketOrder(
action, quantity, float(limit_price), float(take_profit), float(stop_loss)
)
trades_list = [ib.placeOrder(contract, leg) for leg in bracket]
else:
order = MarketOrder(action, quantity)
trades_list = [ib.placeOrder(contract, order)]
primary_trade = trades_list[0]
for _ in range(60):
await asyncio.sleep(0.5)
if primary_trade.isDone():
break
if not primary_trade.isDone() or not primary_trade.fills:
for t in trades_list:
try:
ib.cancelOrder(t.order)
except Exception:
pass
raise HTTPException(status_code=504, detail="Order did not fill in time")
fill_price = primary_trade.fills[0].execution.price
log_trade(symbol, action, quantity, fill_price, cursor, conn, lock)
return {
"status": "success",
"symbol": symbol,
"action": action,
"quantity": quantity,
"order_type": order_type,
"fill_price": fill_price,
}
except HTTPException:
raise
except Exception as exc:
logger.error(f"Webhook error: {exc}")
raise HTTPException(status_code=500, detail=str(exc))