feat: 90-day price range Y-axis scaling with breakthrough annotations
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
Backend:
- GET /api/market/prices/{ticker} now returns { bars, range_90d }
with 90-day low/high computed from market_snapshots
- POST /api/market/backfill/{ticker} fetches 90 days of daily bars
from Polygon and inserts missing bars into market_snapshots
- POST /api/market/backfill-all does the same for all active tickers
Frontend:
- Right Y-axis domain scaled to 90-day min/max (with 3% padding)
- Green dashed reference line at 90-day high
- Red dashed reference line at 90-day low
- Labels show exact price on each reference line
- Default limit bumped to 200 bars
This commit is contained in:
@@ -256,8 +256,13 @@ export interface MarketPrice {
|
|||||||
captured_at: string;
|
captured_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMarketPrices(ticker: string | undefined, limit = 30) {
|
export interface MarketPriceResponse {
|
||||||
return useGet<MarketPrice[]>(
|
bars: MarketPrice[];
|
||||||
|
range_90d: { low: number | null; high: number | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarketPrices(ticker: string | undefined, limit = 200) {
|
||||||
|
return useGet<MarketPriceResponse>(
|
||||||
['market-prices', ticker, limit],
|
['market-prices', ticker, limit],
|
||||||
'query',
|
'query',
|
||||||
`/api/market/prices/${ticker}?limit=${limit}`,
|
`/api/market/prices/${ticker}?limit=${limit}`,
|
||||||
@@ -265,6 +270,18 @@ export function useMarketPrices(ticker: string | undefined, limit = 30) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Backfill 90 days of daily bars from Polygon for a single ticker. */
|
||||||
|
export function useBackfillMarketPrices() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ticker: string) =>
|
||||||
|
apiPost<{ ticker: string; inserted: number; total_bars: number }>('query', `/api/market/backfill/${ticker}`, {}),
|
||||||
|
onSuccess: (_data, ticker) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['market-prices', ticker] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useTrend(id: string | undefined) {
|
export function useTrend(id: string | undefined) {
|
||||||
return useGet<TrendSummary>(['trend', id], 'query', `/api/trends/${id}`, !!id);
|
return useGet<TrendSummary>(['trend', id], 'query', `/api/trends/${id}`, !!id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { DataTable, type Column } from '../components/DataTable';
|
|||||||
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary, MarketPrice } from '../api/hooks';
|
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary, MarketPrice } from '../api/hooks';
|
||||||
import {
|
import {
|
||||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
CartesianGrid, Legend,
|
CartesianGrid, Legend, ReferenceLine,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
const sourceCols: Column<Source>[] = [
|
const sourceCols: Column<Source>[] = [
|
||||||
@@ -46,7 +46,9 @@ export function CompanyDetailPage() {
|
|||||||
const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 });
|
const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 });
|
||||||
const [selectedWindow, setSelectedWindow] = useState('7d');
|
const [selectedWindow, setSelectedWindow] = useState('7d');
|
||||||
const { data: trendHistory } = useTrendHistory({ ticker: company?.ticker, window: selectedWindow, limit: 500 });
|
const { data: trendHistory } = useTrendHistory({ ticker: company?.ticker, window: selectedWindow, limit: 500 });
|
||||||
const { data: marketPrices } = useMarketPrices(company?.ticker, 200);
|
const { data: marketPriceData } = useMarketPrices(company?.ticker, 200);
|
||||||
|
const marketPrices = marketPriceData?.bars ?? [];
|
||||||
|
const range90d = marketPriceData?.range_90d ?? { low: null, high: null };
|
||||||
const { data: positions } = usePositions(company?.ticker);
|
const { data: positions } = usePositions(company?.ticker);
|
||||||
const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends');
|
const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends');
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export function CompanyDetailPage() {
|
|||||||
{tab === 'trends' && (
|
{tab === 'trends' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PositionCard positions={positions ?? []} ticker={company.ticker} />
|
<PositionCard positions={positions ?? []} ticker={company.ticker} />
|
||||||
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} marketPrices={marketPrices ?? []} selectedWindow={selectedWindow} onWindowChange={setSelectedWindow} />
|
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} marketPrices={marketPrices} range90d={range90d} selectedWindow={selectedWindow} onWindowChange={setSelectedWindow} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -680,7 +682,7 @@ function PositionCard({ positions, ticker }: { positions: import('../api/hooks')
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, selectedWindow, onWindowChange }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string; marketPrices: MarketPrice[]; selectedWindow: string; onWindowChange: (w: string) => void }) {
|
function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, range90d, selectedWindow, onWindowChange }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string; marketPrices: MarketPrice[]; range90d: { low: number | null; high: number | null }; selectedWindow: string; onWindowChange: (w: string) => void }) {
|
||||||
|
|
||||||
// Determine the time range for the selected window to filter data
|
// Determine the time range for the selected window to filter data
|
||||||
const windowHours: Record<string, number> = {
|
const windowHours: Record<string, number> = {
|
||||||
@@ -816,11 +818,34 @@ function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, selecte
|
|||||||
tick={{ fill: '#e879f9', fontSize: 11 }}
|
tick={{ fill: '#e879f9', fontSize: 11 }}
|
||||||
tickLine={{ stroke: '#475569' }}
|
tickLine={{ stroke: '#475569' }}
|
||||||
tickFormatter={(v) => `$${v}`}
|
tickFormatter={(v) => `$${v}`}
|
||||||
domain={['dataMin - 2', 'dataMax + 2']}
|
domain={[
|
||||||
|
range90d.low != null ? Math.floor(range90d.low * 0.97) : 'dataMin - 2',
|
||||||
|
range90d.high != null ? Math.ceil(range90d.high * 1.03) : 'dataMax + 2',
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={TrendTooltip} />
|
<Tooltip content={TrendTooltip} />
|
||||||
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: 12 }} />
|
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: 12 }} />
|
||||||
|
{hasPrice && range90d.high != null && (
|
||||||
|
<ReferenceLine
|
||||||
|
yAxisId="right"
|
||||||
|
y={range90d.high}
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeDasharray="6 3"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
label={{ value: `90d High $${range90d.high.toFixed(2)}`, position: 'insideTopRight', fill: '#22c55e', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasPrice && range90d.low != null && (
|
||||||
|
<ReferenceLine
|
||||||
|
yAxisId="right"
|
||||||
|
y={range90d.low}
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeDasharray="6 3"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
label={{ value: `90d Low $${range90d.low.toFixed(2)}`, position: 'insideBottomRight', fill: '#ef4444', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Line
|
<Line
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
|||||||
+121
-3
@@ -471,12 +471,13 @@ async def list_trend_history(
|
|||||||
@app.get("/api/market/prices/{ticker}")
|
@app.get("/api/market/prices/{ticker}")
|
||||||
async def get_market_prices(
|
async def get_market_prices(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
limit: int = Query(default=30, le=200),
|
limit: int = Query(default=200, le=500),
|
||||||
):
|
):
|
||||||
"""Return historical close prices for a ticker from market_snapshots.
|
"""Return historical close prices for a ticker from market_snapshots.
|
||||||
|
|
||||||
Each row has a bar_date (from the Polygon bar timestamp) and OHLCV data.
|
Each row has a bar_date (from the Polygon bar timestamp) and OHLCV data.
|
||||||
Ordered oldest-first for chart rendering.
|
Ordered oldest-first for chart rendering. Also returns 90-day high/low
|
||||||
|
computed from all bars in the last 90 days.
|
||||||
"""
|
"""
|
||||||
ticker = ticker.upper()
|
ticker = ticker.upper()
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
@@ -515,7 +516,124 @@ async def get_market_prices(
|
|||||||
"bar_timestamp": bar_ts,
|
"bar_timestamp": bar_ts,
|
||||||
"captured_at": r["captured_at"].isoformat() if r["captured_at"] else None,
|
"captured_at": r["captured_at"].isoformat() if r["captured_at"] else None,
|
||||||
})
|
})
|
||||||
return results
|
|
||||||
|
# Compute 90-day high/low from all bars in the window
|
||||||
|
cutoff_90d = datetime.now(timezone.utc) - timedelta(days=90)
|
||||||
|
range_row = await pool.fetchrow(
|
||||||
|
"""SELECT
|
||||||
|
MIN((data->>'l')::float) AS low_90d,
|
||||||
|
MAX((data->>'h')::float) AS high_90d
|
||||||
|
FROM market_snapshots
|
||||||
|
WHERE ticker = $1 AND snapshot_type = 'bar'
|
||||||
|
AND captured_at >= $2""",
|
||||||
|
ticker, cutoff_90d,
|
||||||
|
)
|
||||||
|
low_90d = range_row["low_90d"] if range_row else None
|
||||||
|
high_90d = range_row["high_90d"] if range_row else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bars": results,
|
||||||
|
"range_90d": {"low": low_90d, "high": high_90d},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/market/backfill/{ticker}")
|
||||||
|
async def backfill_market_prices(ticker: str, days: int = Query(default=90, le=365)):
|
||||||
|
"""Backfill daily OHLCV bars from Polygon for the last N days.
|
||||||
|
|
||||||
|
Fetches daily aggregate bars from Polygon's range endpoint and inserts
|
||||||
|
any missing bars into market_snapshots (deduped by bar timestamp).
|
||||||
|
Returns the number of bars inserted.
|
||||||
|
"""
|
||||||
|
ticker = ticker.upper()
|
||||||
|
api_key = config.market_data.api_key
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(503, "No market data API key configured")
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
to_date = date.today().isoformat()
|
||||||
|
from_date = (date.today() - timedelta(days=days)).isoformat()
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"{config.market_data.base_url}/v2/aggs/ticker/{ticker}"
|
||||||
|
f"/range/1/day/{from_date}/{to_date}"
|
||||||
|
)
|
||||||
|
params = {"apiKey": api_key, "adjusted": "true", "sort": "asc", "limit": "500"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.get(url, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
bars = data.get("results", [])
|
||||||
|
if not bars:
|
||||||
|
return {"ticker": ticker, "inserted": 0, "total_bars": 0}
|
||||||
|
|
||||||
|
# Find existing bar timestamps to avoid duplicates
|
||||||
|
existing = await pool.fetch(
|
||||||
|
"""SELECT DISTINCT (data->>'t')::bigint AS bar_ts
|
||||||
|
FROM market_snapshots
|
||||||
|
WHERE ticker = $1 AND snapshot_type = 'bar'""",
|
||||||
|
ticker,
|
||||||
|
)
|
||||||
|
existing_ts = {r["bar_ts"] for r in existing if r["bar_ts"] is not None}
|
||||||
|
|
||||||
|
# Look up company_id (nullable)
|
||||||
|
company_row = await pool.fetchrow(
|
||||||
|
"SELECT id FROM companies WHERE ticker = $1", ticker,
|
||||||
|
)
|
||||||
|
company_id = company_row["id"] if company_row else None
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
for bar in bars:
|
||||||
|
bar_ts = bar.get("t")
|
||||||
|
if bar_ts is None or bar_ts in existing_ts:
|
||||||
|
continue
|
||||||
|
bar_json = json.dumps(bar)
|
||||||
|
content_hash = hashlib.sha256(bar_json.encode()).hexdigest()
|
||||||
|
captured_at = datetime.fromtimestamp(bar_ts / 1000, tz=timezone.utc)
|
||||||
|
await pool.execute(
|
||||||
|
"""INSERT INTO market_snapshots
|
||||||
|
(company_id, ticker, snapshot_type, data, source_provider, captured_at, content_hash)
|
||||||
|
VALUES ($1, $2, 'bar', $3::jsonb, 'polygon_backfill', $4, $5)""",
|
||||||
|
company_id, ticker, bar_json, captured_at, content_hash,
|
||||||
|
)
|
||||||
|
existing_ts.add(bar_ts)
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
return {"ticker": ticker, "inserted": inserted, "total_bars": len(bars), "days": days}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/market/backfill-all")
|
||||||
|
async def backfill_all_market_prices(days: int = Query(default=90, le=365)):
|
||||||
|
"""Backfill daily bars for ALL active companies from Polygon.
|
||||||
|
|
||||||
|
Iterates through all active tickers and calls the per-ticker backfill.
|
||||||
|
Returns a summary of results per ticker.
|
||||||
|
"""
|
||||||
|
api_key = config.market_data.api_key
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(503, "No market data API key configured")
|
||||||
|
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT ticker FROM companies WHERE active = TRUE ORDER BY ticker",
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
ticker = row["ticker"]
|
||||||
|
try:
|
||||||
|
result = await backfill_market_prices(ticker, days)
|
||||||
|
results.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Backfill failed for %s: %s", ticker, e)
|
||||||
|
results.append({"ticker": ticker, "inserted": 0, "error": str(e)})
|
||||||
|
|
||||||
|
total_inserted = sum(r.get("inserted", 0) for r in results)
|
||||||
|
return {"total_inserted": total_inserted, "tickers": len(results), "details": results}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/trends/{trend_id}")
|
@app.get("/api/trends/{trend_id}")
|
||||||
|
|||||||
Reference in New Issue
Block a user