Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions doc/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ Configuration is optional. Without any, the pipeline writes into the data
directory `run.sh` exports, fetches the whole configured plan window, and
sends no mail.

The settings worth knowing about are under `jquants`, which describe the plan
rather than the program:
The operational settings below cover the configured publication window and the
J-Quants client's request behavior:

| Key | Default | What it is |
|---|---|---|
Expand Down
16 changes: 8 additions & 8 deletions finance/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,9 @@ def latest_available(self, today: date) -> date:
"""
Return the newest date the plan publishes as of the given day.

On the Free plan this is twelve weeks back. Asking for anything
newer is answered with nothing, so the pipeline stops at this
date rather than requesting a window it cannot be given.
The configured delay is applied to today. The pipeline stops at
that date rather than requesting a window outside the configured
publication window.
"""
return today - timedelta(days=self.delay_days)

Expand Down Expand Up @@ -238,11 +238,11 @@ def fetch_window(self, today: date) -> tuple[date, date]:
"""
Return the date range a fetch on the given day may ask for.

The end is the newest date the plan publishes. The start is the
configured start date, raised to the oldest date the plan still
keeps: a request for 2014 against a plan holding two years is
not an error, it is simply answered from where the data begins,
and clamping says so in the log instead of implying otherwise.
The end is the newest date the configured plan window permits.
The start is the configured start date, raised to the oldest
date that window keeps. A start date earlier than the window is
not an error; it is clamped to the window and the change is
recorded in the log.
"""
end = self.jquants.latest_available(today)
earliest = self.jquants.earliest_available(today)
Expand Down
4 changes: 2 additions & 2 deletions finance/datasources/jquants.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,8 @@ def __init__(self, settings: JQuantsSettings, session: object | None = None) ->
"""
if not settings.has_api_key():
raise AuthenticationError(
"No J-Quants API key is configured. Export JQUANTS_API_KEY with the key "
"issued from the J-Quants dashboard."
"No J-Quants API key is configured. Export JQUANTS_API_KEY with a valid "
"key for the configured J-Quants subscription."
)
self.settings = settings
self._session = session
Expand Down
8 changes: 4 additions & 4 deletions finance/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
#
# The data source errors are split further than the rest because the
# operator's response differs by kind. A rejected API key needs a
# configuration change; a rate limit needs a slower schedule; a dataset
# the subscribed plan does not carry needs neither, and is not a fault
# to be fixed. Collapsing them into one type would leave the nightly
# log unable to say which of the three happened.
# configuration change; a rate limit needs a slower request pace; an
# unavailable dataset or date can be skipped; an invalid stock code
# needs its input corrected. Collapsing them into one type would leave
# the nightly log unable to distinguish these conditions.
#
# Author: id774 (More info: http://id774.net)
# Source Code: https://github.com/id774/finance
Expand Down
22 changes: 10 additions & 12 deletions finance/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@
# finance/storage.py: File input and output
#
# Description:
# Every read and write of a generated file passes through here: the raw
# price CSV, the technical indicator CSV, the summary tables, their
# dated copies and the pickled models.
# Centralize file I/O for the raw price CSV, technical indicator CSV,
# summary tables, dated summary copies, data_source.txt and pickled
# models.
#
# The formats are a contract with finance-dashboard, so they are stated
# once in this module rather than being spelled out at each call site.
# The two that matter are that a price or indicator file is comma
# separated with a Date index label, and that a summary file is tab
# separated with a Code index label and is read positionally by the
# dashboard. doc/DATA_CONTRACT.md is the normative description.
# File formats shared with finance-dashboard are described normatively
# in doc/DATA_CONTRACT.md. This module keeps the matching separators,
# index labels, data_source.txt file name and key order in constants so
# call sites do not restate them.
#
# This module decides no path. It is told where to write, so that the
# layers above it own the layout and a test can point it at a temporary
Expand Down Expand Up @@ -217,9 +215,9 @@ def write_data_source(
generated: The day the pipeline ran.
last_trading_day: The newest trading day the data covers, or
None when the run analysed nothing. An unknown date is
written as an empty value rather than as today, because a
consumer showing today for data that is twelve weeks old is
the exact misreading this file exists to prevent.
written as an empty value rather than as today, because
substituting the run date would make older or unknown data
look more current than it is.

Raises:
StorageError: The file cannot be written.
Expand Down
22 changes: 11 additions & 11 deletions run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
# indicator files, then the mail that reads the summaries, and last the
# long and short charts, which nothing else consumes.
#
# That order is a specification, not a habit. Steps 2 to 6 read the
# ti_CODE.csv files step 1 writes, and steps 7 and 8 read the summaries
# steps 2 and 4 write.
# That order is a specification, not a habit. The summary runs read the
# ti_CODE.csv files written by the updating chart run, and the mail runs
# read the summaries produced earlier in the job.
#
# Only step 1 passes --update. It is the run that fetches prices,
# Only the updating chart run passes --update. It fetches prices,
# rewrites stock_CODE.csv and ti_CODE.csv, retrains the models and
# records where the data came from. The long and short runs draw their
# charts from what step 1 already stored, so the job makes one pass
# over the network per stock per day rather than three, and needs no
# records where the data came from. The long and short chart runs draw
# from the stored data, so they perform no additional fetch and need no
# API key of their own.
#
# A step that fails is reported and the job continues to the next one,
Expand All @@ -39,10 +38,11 @@
# ./run.sh -h | --help
#
# Environment Variables:
# - JQUANTS_API_KEY: The J-Quants API key. Required by step 1, which is
# the only step that fetches. It is not set here: put it in an
# environment file the deployment owns, readable only by the user
# the job runs as, and never in this script or in the repository.
# - JQUANTS_API_KEY: The J-Quants API key. Required by the updating
# chart run, which is the only run that fetches. It is not set here:
# put it in an environment file the deployment owns, readable only
# by the user the job runs as, and never in this script or in the
# repository.
# - WORK_DIR: Deployment root. Defaults to /var/stock.
# - ENV_FILE: A file of KEY=value lines sourced before the job, holding
# JQUANTS_API_KEY. Defaults to $WORK_DIR/env, and is skipped when
Expand Down
5 changes: 3 additions & 2 deletions test/integration/test_jquants_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
# test/test_datasources.py are what the adapter is really tested
# against.
#
# The date range asked for is inside the Free plan's published window,
# so that a Free subscription can run this as well as a paid one.
# The date range is derived from the configured publication window.
# The test does not hard-code a provider plan's current delay or compare
# subscription tiers.
#
# Test Cases:
# - The endpoint answers and the response normalizes to the canonical
Expand Down
72 changes: 36 additions & 36 deletions test/test_plan_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,14 @@
# plan decide the dates a run may ask for, and that the rest of the
# pipeline behaves correctly at the edges of that window.
#
# These are the tests of the constraint the Free plan imposes. It
# publishes nothing newer than twelve weeks ago and keeps two years
# behind that, and this repository treats both as the specification
# rather than as a defect to work around. What that has to mean in
# practice is asserted here: a fetch is never asked for a date the plan
# cannot answer, a start date older than the plan is raised rather than
# refused, stored data that already reaches the newest published date
# is left alone instead of being re-requested every night, and a
# summary measures staleness against that date rather than against
# today -- which, if it did not, would drop every stock as stale and
# write an empty table every evening.
# These tests use an explicit publication-delay and retention fixture.
# Its values are test inputs and program defaults, not assertions about
# current provider plan terms. The behavior under that configured
# window is asserted here: a fetch is never asked for a date outside
# the window, a start date older than the window is raised to its lower
# bound, stored data that reaches the newest configured date is left
# alone instead of being re-requested every night, and a summary
# measures staleness against that date rather than against today.
#
# Test Cases:
# - The window ends at the newest date the plan publishes.
Expand Down Expand Up @@ -62,41 +59,43 @@

TODAY = date(2026, 8, 14)

FREE_PLAN = JQuantsSettings(api_key="test-key", delay_days=84, retention_days=730)
TEST_PLAN = JQuantsSettings(api_key="test-key", delay_days=84, retention_days=730)


def free(settings):
""" Return the settings with the Free plan window in place. """
return replace(settings, jquants=FREE_PLAN, start_date="")
def with_test_plan(settings):
""" Return the settings with the test plan window in place. """
return replace(settings, jquants=TEST_PLAN, start_date="")


def test_the_window_ends_at_the_newest_published_date():
assert FREE_PLAN.latest_available(TODAY) == TODAY - timedelta(days=84)
assert TEST_PLAN.latest_available(TODAY) == TODAY - timedelta(days=84)


def test_the_window_begins_at_the_oldest_kept_date():
assert FREE_PLAN.earliest_available(TODAY) == TODAY - timedelta(days=84 + 730)
assert TEST_PLAN.earliest_available(TODAY) == TODAY - timedelta(days=84 + 730)


def test_a_start_date_inside_the_window_is_honoured(settings):
inside = (TODAY - timedelta(days=200)).isoformat()
start, end = replace(free(settings), start_date=inside).fetch_window(TODAY)
start, end = replace(with_test_plan(settings), start_date=inside).fetch_window(TODAY)

assert start.isoformat() == inside
assert end == FREE_PLAN.latest_available(TODAY)
assert end == TEST_PLAN.latest_available(TODAY)


def test_a_start_date_before_the_window_is_raised_to_it(settings):
# The value the pipeline used when its source could answer for 2014.
older = replace(free(settings), start_date="2014-10-01")
older = replace(with_test_plan(settings), start_date="2014-10-01")
start, _ = older.fetch_window(TODAY)

assert start == FREE_PLAN.earliest_available(TODAY)
assert start == TEST_PLAN.earliest_available(TODAY)


def test_a_plan_without_a_delay_ends_the_window_at_today(settings):
paid = replace(free(settings), jquants=replace(FREE_PLAN, delay_days=0))
_, end = paid.fetch_window(TODAY)
no_delay = replace(
with_test_plan(settings), jquants=replace(TEST_PLAN, delay_days=0)
)
_, end = no_delay.fetch_window(TODAY)
assert end == TODAY


Expand All @@ -110,24 +109,24 @@ def shifted(frame, last: date):
def test_a_fetch_is_asked_for_the_window_and_never_beyond_it(
settings, stub_source, raw_prices
):
plan = free(settings)
source = stub_source(shifted(raw_prices, FREE_PLAN.latest_available(TODAY)))
plan = with_test_plan(settings)
source = stub_source(shifted(raw_prices, TEST_PLAN.latest_available(TODAY)))
Analysis(plan, source, today=TODAY).run(
AnalysisRequest(code="7203", days=60, update=True)
)

code, start, end = source.calls[0]
assert code == "7203"
assert start == FREE_PLAN.earliest_available(TODAY)
assert end == FREE_PLAN.latest_available(TODAY)
assert start == TEST_PLAN.earliest_available(TODAY)
assert end == TEST_PLAN.latest_available(TODAY)
assert end < TODAY


def test_stored_data_reaching_the_published_date_is_not_re_requested(
settings, stub_source, raw_prices
):
plan = free(settings)
published = FREE_PLAN.latest_available(TODAY)
plan = with_test_plan(settings)
published = TEST_PLAN.latest_available(TODAY)

# Stored history whose last row is the newest date the plan has.
write_price_csv(shifted(raw_prices, published), plan.data_file("stock_7203.csv"))
Expand All @@ -143,8 +142,8 @@ def test_stored_data_reaching_the_published_date_is_not_re_requested(
def test_staleness_is_measured_against_the_published_date(
settings, stub_source, raw_prices, indicator_fixture
):
plan = free(settings)
published = FREE_PLAN.latest_available(TODAY)
plan = with_test_plan(settings)
published = TEST_PLAN.latest_available(TODAY)

shifted(indicator_fixture, published).to_csv(
plan.data_file("ti_7203.csv"), index_label="Date"
Expand All @@ -155,17 +154,18 @@ def test_staleness_is_measured_against_the_published_date(
plan, SummaryRequest(output="summary.csv", stock_list="stocks.txt"), today=TODAY
)

# Twelve weeks old against today, and current against the plan.
# The delayed fixture is current against the configured plan window.
assert result.rows == 1


def test_the_longest_lookback_fits_inside_the_window():
"""
The longest moving average must be computable within the plan.

Two years is about 488 trading days; the longest window the
indicator layer asks for is 200. If a future plan change made this
false, every long indicator would silently become all-NaN.
The configured retention window is converted to an approximate
weekday count. The longest indicator window must fit inside it; if
a future configuration change made this false, every long indicator
would silently become all-NaN.
"""
trading_days = (FREE_PLAN.retention_days / 7) * 5
trading_days = (TEST_PLAN.retention_days / 7) * 5
assert max(SMA_PERIODS) < trading_days
Loading