Skip to content

Commit 7e54fe6

Browse files
authored
Add fred data source (#11)
1 parent b39c1d2 commit 7e54fe6

File tree

8 files changed

+224
-27
lines changed

8 files changed

+224
-27
lines changed

quantflow/cli/app.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
11
import os
22
from dataclasses import dataclass, field
33
from typing import Any
4-
4+
from functools import partial
55
import click
66
from prompt_toolkit import PromptSession
77
from prompt_toolkit.history import FileHistory
88
from rich.console import Console
99
from rich.text import Text
10+
from .commands import fred, stocks, vault
11+
from quantflow.data.vault import Vault
12+
from quantflow.data.fmp import FMP
13+
from quantflow.data.fred import Fred
1014

11-
12-
from . import settings, commands
15+
from . import settings
1316

1417

1518
@click.group()
1619
def qf() -> None:
1720
pass
1821

1922

20-
qf.add_command(commands.exit)
21-
qf.add_command(commands.profile)
22-
qf.add_command(commands.search)
23-
qf.add_command(commands.chart)
23+
@qf.command()
24+
def exit() -> None:
25+
"""Exit the program"""
26+
raise click.Abort()
27+
28+
29+
qf.add_command(vault.vault)
30+
qf.add_command(stocks.stocks)
31+
qf.add_command(fred.fred)
2432

2533

2634
@dataclass
2735
class QfApp:
2836
console: Console = field(default_factory=Console)
37+
vault: Vault = field(default_factory=partial(Vault, settings.VAULT_FILE_PATH))
2938

3039
def __call__(self) -> None:
3140
os.makedirs(settings.SETTINGS_DIRECTORY, exist_ok=True)
@@ -70,3 +79,15 @@ def handle_command(self, text: str) -> None:
7079
self.error(e)
7180
except click.exceptions.UsageError as e:
7281
self.error(e)
82+
83+
def fmp(self) -> FMP:
84+
if key := self.vault.get("fmp"):
85+
return FMP(key=key)
86+
else:
87+
raise click.UsageError("No FMP API key found")
88+
89+
def fred(self) -> Fred:
90+
if key := self.vault.get("fred"):
91+
return Fred(key=key)
92+
else:
93+
raise click.UsageError("No FRED API key found")

quantflow/cli/commands/__init__.py

Whitespace-only changes.

quantflow/cli/commands/fred.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
import click
4+
import asyncio
5+
import pandas as pd
6+
from typing import TYPE_CHECKING
7+
from fluid.utils.data import compact_dict
8+
from fluid.utils.http_client import HttpResponseError
9+
from ccy.cli.console import df_to_rich
10+
from quantflow.data.fmp import FMP
11+
from .stocks import from_context
12+
13+
14+
FREQUENCIES = tuple(FMP().historical_frequencies())
15+
16+
if TYPE_CHECKING:
17+
from quantflow.cli.app import QfApp
18+
19+
20+
@click.group(invoke_without_command=True)
21+
@click.pass_context
22+
def fred(ctx: click.Context) -> None:
23+
"""Federal Reserve of St. Louis data"""
24+
if ctx.invoked_subcommand is None:
25+
app = from_context(ctx)
26+
app.print("Welcome to FRED data commands!")
27+
app.print(ctx.get_help())
28+
29+
30+
@fred.command()
31+
@click.pass_context
32+
@click.argument("category-id", required=False)
33+
def subcategories(ctx: click.Context, category_id: str | None = None) -> None:
34+
"""List subcategories for a Fred category"""
35+
app = from_context(ctx)
36+
try:
37+
data = asyncio.run(get_subcategories(app, category_id))
38+
except HttpResponseError as e:
39+
app.error(e)
40+
else:
41+
df = pd.DataFrame(data["categories"], columns=["id", "name"])
42+
app.print(df_to_rich(df))
43+
44+
45+
@fred.command()
46+
@click.pass_context
47+
@click.argument("category-id")
48+
def series(ctx: click.Context, category_id: str) -> None:
49+
"""List series for a Fred category"""
50+
app = from_context(ctx)
51+
try:
52+
data = asyncio.run(get_series(app, category_id))
53+
except HttpResponseError as e:
54+
app.error(e)
55+
else:
56+
app.print(data)
57+
# df = pd.DataFrame(data["categories"], columns=["id", "name"])
58+
# app.print(df_to_rich(df))
59+
60+
61+
async def get_subcategories(app: QfApp, category_id: str | None) -> dict:
62+
async with app.fred() as cli:
63+
return await cli.subcategories(params=compact_dict(category_id=category_id))
64+
65+
66+
async def get_series(app: QfApp, category_id: str) -> dict:
67+
async with app.fred() as cli:
68+
return await cli.series(params=compact_dict(category_id=category_id))
Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,23 @@ def from_context(ctx: click.Context) -> QfApp:
1818
return ctx.obj # type: ignore
1919

2020

21-
@click.command()
22-
def exit() -> None:
23-
"""Exit the program"""
24-
raise click.Abort()
21+
@click.group(invoke_without_command=True)
22+
@click.pass_context
23+
def stocks(ctx: click.Context) -> None:
24+
"""Stocks commands"""
25+
if ctx.invoked_subcommand is None:
26+
app = from_context(ctx)
27+
app.print("Welcome to the stocks commands!")
28+
app.print(ctx.get_help())
2529

2630

27-
@click.command()
31+
@stocks.command()
2832
@click.argument("symbol")
2933
@click.pass_context
3034
def profile(ctx: click.Context, symbol: str) -> None:
3135
"""Company profile"""
3236
app = from_context(ctx)
33-
data = asyncio.run(get_profile(symbol))
37+
data = asyncio.run(get_profile(app, symbol))
3438
if not data:
3539
raise click.UsageError(f"Company {symbol} not found - try searching")
3640
else:
@@ -40,18 +44,18 @@ def profile(ctx: click.Context, symbol: str) -> None:
4044
app.print(df_to_rich(df))
4145

4246

43-
@click.command()
47+
@stocks.command()
4448
@click.argument("text")
4549
@click.pass_context
4650
def search(ctx: click.Context, text: str) -> None:
4751
"""Search companies"""
4852
app = from_context(ctx)
49-
data = asyncio.run(search_company(text))
53+
data = asyncio.run(search_company(app, text))
5054
df = pd.DataFrame(data, columns=["symbol", "name", "currency", "stockExchange"])
5155
app.print(df_to_rich(df))
5256

5357

54-
@click.command()
58+
@stocks.command()
5559
@click.argument("symbol")
5660
@click.option(
5761
"-h",
@@ -76,9 +80,13 @@ def search(ctx: click.Context, text: str) -> None:
7680
default="",
7781
help="Frequency of data - if not provided it is daily",
7882
)
79-
def chart(symbol: str, height: int, length: int, frequency: str) -> None:
83+
@click.pass_context
84+
def chart(
85+
ctx: click.Context, symbol: str, height: int, length: int, frequency: str
86+
) -> None:
8087
"""Symbol chart"""
81-
df = asyncio.run(get_prices(symbol, frequency))
88+
app = from_context(ctx)
89+
df = asyncio.run(get_prices(app, symbol, frequency))
8290
if df.empty:
8391
raise click.UsageError(
8492
f"No data for {symbol} - are you sure the symbol exists?"
@@ -87,16 +95,16 @@ def chart(symbol: str, height: int, length: int, frequency: str) -> None:
8795
print(plot(data, {"height": height}))
8896

8997

90-
async def get_prices(symbol: str, frequency: str) -> pd.DataFrame:
91-
async with FMP() as cli:
98+
async def get_prices(app: QfApp, symbol: str, frequency: str) -> pd.DataFrame:
99+
async with app.fmp() as cli:
92100
return await cli.prices(symbol, frequency)
93101

94102

95-
async def get_profile(symbol: str) -> list[dict]:
96-
async with FMP() as cli:
103+
async def get_profile(app: QfApp, symbol: str) -> list[dict]:
104+
async with app.fmp() as cli:
97105
return await cli.profile(symbol)
98106

99107

100-
async def search_company(text: str) -> list[dict]:
101-
async with FMP() as cli:
108+
async def search_company(app: QfApp, text: str) -> list[dict]:
109+
async with app.fmp() as cli:
102110
return await cli.search(text)

quantflow/cli/commands/vault.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import click
2+
from .stocks import from_context
3+
4+
5+
API_KEYS = ("fmp", "fred")
6+
7+
8+
@click.group()
9+
def vault() -> None:
10+
"""Manage vault secrets"""
11+
pass
12+
13+
14+
@vault.command()
15+
@click.argument("key", type=click.Choice(API_KEYS))
16+
@click.argument("value")
17+
@click.pass_context
18+
def add(ctx: click.Context, key: str, value: str) -> None:
19+
"""Add an API key to the vault"""
20+
vault = from_context(ctx).vault
21+
vault.add(key, value)
22+
23+
24+
@vault.command()
25+
@click.argument("key")
26+
@click.pass_context
27+
def delete(ctx: click.Context, key: str) -> None:
28+
"""Delete an API key from the vault"""
29+
app = from_context(ctx)
30+
if app.vault.delete(key):
31+
app.print(f"Deleted key {key}")
32+
else:
33+
app.error(f"Key {key} not found")
34+
35+
36+
@vault.command()
37+
@click.argument("key")
38+
@click.pass_context
39+
def show(ctx: click.Context, key: str) -> None:
40+
"""Show the value of an API key"""
41+
app = from_context(ctx)
42+
if value := app.vault.get(key):
43+
app.print(value)
44+
else:
45+
app.error(f"Key {key} not found")
46+
47+
48+
@vault.command()
49+
@click.pass_context
50+
def keys(ctx: click.Context) -> None:
51+
"""Show the keys in the vault"""
52+
app = from_context(ctx)
53+
for key in app.vault.keys():
54+
app.print(key)

quantflow/cli/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
SETTINGS_DIRECTORY = HOME_DIRECTORY / ".quantflow"
1010
SETTINGS_ENV_FILE = SETTINGS_DIRECTORY / ".env"
1111
HIST_FILE_PATH = SETTINGS_DIRECTORY / ".quantflow.his"
12+
VAULT_FILE_PATH = SETTINGS_DIRECTORY / ".vault"

quantflow/data/fred.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@ class Fred(AioHttpClient):
99
url: str = "https://api.stlouisfed.org/fred"
1010
key: str = field(default_factory=lambda: os.environ.get("FRED_API_KEY", ""))
1111

12-
async def categiories(self, **kw: Any) -> list[dict]:
12+
async def categiories(self, **kw: Any) -> dict:
1313
return await self.get_path("category", **kw)
1414

15+
async def subcategories(self, **kw: Any) -> dict:
16+
return await self.get_path("category/children", **kw)
17+
18+
async def series(self, **kw: Any) -> dict:
19+
return await self.get_path("category/series", **kw)
20+
1521
# Internals
16-
async def get_path(self, path: str, **kw: Any) -> list[dict]:
22+
async def get_path(self, path: str, **kw: Any) -> dict:
1723
result = await self.get(f"{self.url}/{path}", **self.params(**kw))
18-
return cast(list[dict], result)
24+
return cast(dict, result)
1925

2026
def params(self, params: dict | None = None, **kw: Any) -> dict:
2127
params = params.copy() if params is not None else {}

quantflow/data/vault.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from pathlib import Path
2+
3+
4+
class Vault:
5+
6+
def __init__(self, path: str | Path) -> None:
7+
self.path = Path(path)
8+
self.path.touch(exist_ok=True)
9+
self.data = self.load()
10+
11+
def load(self) -> dict[str, str]:
12+
data = {}
13+
with open(self.path) as file:
14+
for line in file:
15+
key, value = line.strip().split("=")
16+
data[key] = value
17+
return data
18+
19+
def add(self, key: str, value: str) -> None:
20+
self.data[key] = value
21+
self.save()
22+
23+
def delete(self, key: str) -> bool:
24+
if self.data.pop(key, None) is not None:
25+
self.save()
26+
return True
27+
return False
28+
29+
def get(self, key: str) -> str | None:
30+
return self.data.get(key)
31+
32+
def keys(self) -> list[str]:
33+
return sorted(self.data)
34+
35+
def save(self) -> None:
36+
with open(self.path, "w") as file:
37+
for key in sorted(self.data):
38+
value = self.data[key]
39+
file.write(f"{key}={value}\n")

0 commit comments

Comments
 (0)